Skip to content

perf(A): cut server render cost by ~5x - #584

Open
birkskyum wants to merge 1 commit into
solidjs:mainfrom
birkskyum:perf/anchor-ssr
Open

perf(A): cut server render cost by ~5x#584
birkskyum wants to merge 1 commit into
solidjs:mainfrom
birkskyum:perf/anchor-ssr

Conversation

@birkskyum

Copy link
Copy Markdown
Member

Fixes #583.

<A> spent most of its server render time in mergeProps and splitProps, building accessor-backed prop objects that a one shot renderToString can never observe. In a 1000 link table the component rendered in 5.89 ms; it now renders in 1.09 ms, a 5.4x improvement, which lands within 10% of a hand written anchor that does no prop plumbing at all.

What changed

  • Drop the mergeProps that existed only to default activeClass / inactiveClass, and read the defaults with ?? at the point of use. Same semantics, since mergeProps also only falls back on undefined, and the reads stay inside the reactive JSX expression.
  • Share the normalized location.pathname across links. Every <A> on a page ran normalizePath + decodeURI + toLowerCase + a regex on the same string; a one entry cache collapses that to once per navigation. It is a pure function of the input, so a miss can only cost a recompute.
  • Skip JSON.stringify when there is no state. JSON.stringify(undefined) returns undefined, so the attribute was already omitted.
  • Build the classList in one object instead of up to three spreads.
  • On the server, when the caller passes nothing beyond the props <A> consumes itself, skip splitProps and the JSX spread entirely. This is the largest single win: the JSX spread compiles to another mergeProps per link.

The last one is gated on isServer deliberately. A server render is one shot, so the props key set cannot grow after the check. On the client it can, via a reactive spread like <A {...signal()} />, and taking the fast path there would silently drop keys added later. I verified that case both ways. Gating also means client builds constant fold the branch away.

Verification

Output parity. 22 prop combinations rendered through renderToString (active/inactive, exact and parent matches, end, custom activeClass/inactiveClass, user class, user classList, state, target/rel, replace/noScroll/preload, id/aria-label, relative href, query and hash, trailing slash, mixed case, external href, nested children, no children) are byte identical to main, except for one insignificant space inside the tag on the fast path, link="true"> instead of link="true" >.

Client DOM output is byte identical on main, including the dynamic spread case above.

Tests. <A> had no test coverage. This adds:

  • test/anchor.spec.tsx, 15 jsdom tests covering href resolution, active state, end, class handling, classList merging, state serialization, prop forwarding, children, and active class updates across navigation. All 15 also pass against unmodified main, so they are characterization tests rather than tests written to fit the new code.
  • test/ssr/anchor.spec.tsx, 10 tests covering the server fast path and the spread fallback, plus an assertion that the two paths agree. 8 of these also pass against main; the 2 that do not are the ones asserting the exact fast path serialization noted above.

SSR needs the SSR JSX transform and the real isServer, so it cannot share the DOM config or its setup file. That is vitest.ssr.config.ts and a test:ssr script, wired into pnpm test.

Full suite: 274 DOM tests, 10 SSR tests, test:types clean.

Size. Client bundles get slightly smaller, since the server branch folds away and A no longer pulls in mergeProps. Vite production build importing A, Router, Route:

main this PR
bundle 43663 B 42233 B
gzipped 13485 B 13117 B

Benchmark

Reproduction is in #583. Node 26.3.0, Apple silicon, 1000 links, 200 renders after 30 warmup:

                        main      this PR
plain <a> x1000       0.30 ms     0.30 ms
<A> x1000             5.89 ms     1.09 ms

Profile before: mergeProps 24%, splitProps/split 16%, GC 22% of active CPU. After, the remaining <A> cost is dominated by the work that actually has to happen per link, resolving and normalizing the href.

Notes

  • link in the JSX attribute augmentation is widened from boolean to boolean | string, needed to write link="true" on the fast path so its serialization matches what the spread path produces.
  • children is now in the splitProps key list and rendered explicitly, so both paths handle it the same way. Laziness is preserved, the compiler wraps props.children in a memo.

@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f9f852

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/router Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

<A> spent most of its server render time building accessor-backed prop objects
that a one shot renderToString can never observe. In a 1000 link table the
component rendered in 5.9ms; it now renders in 1.1ms.

- drop the mergeProps used only to default activeClass/inactiveClass, and read
  the defaults with ?? at the point of use instead
- share the normalized location.pathname across links rather than recomputing
  normalizePath + decodeURI + toLowerCase once per link
- read props.state once and skip JSON.stringify when it is undefined
- on the server, when the caller passes nothing beyond the props <A> consumes
  itself, skip splitProps and the JSX spread. Gated on isServer because a
  server render is one shot, so the key set cannot grow after the check, and
  the branch is constant folded out of client builds

children stays in the spread path so innerHTML/textContent precedence and the
order a ref observes are unchanged; it is only rendered explicitly on the fast
path, which by definition cannot receive those props. The class map keeps its
object literal form so __proto__ class names stay own properties.

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 the
fast path. Client bundles shrink by ~400 bytes gzipped.

Adds client and server test coverage for <A>, which had none.
@birkskyum

Copy link
Copy Markdown
Member Author

Pushed a revision. An adversarial review of the first version turned up four behavior differences from main that I had missed. All four are fixed, and each now has a regression test that fails on the previous commit and passes on both main and this branch.

1. children broke innerHTML / textContent and ref ordering

Moving children into the splitProps list meant the fallback always supplied an explicit children expression, so ssrElement ignored child-like props from rest:

main previous commit
<A innerHTML="<b>inside</b>" /> <b>inside</b> empty anchor
<A textContent="inside" /> inside empty anchor
innerHTML + children innerHTML wins children won

On the client a ref callback saw "" instead of "child", because the spread processed ref before the explicit insertion.

Fixed by splitting the key sets. children stays out of splitProps and the fallback is a self-closing <a {...rest} ... /> exactly as before; children is only in the fast path whitelist and only rendered explicitly there, where innerHTML cannot occur by construction.

2. state was read twice

The !== undefined check and the JSON.stringify were separate reads, so a getter-backed state was observed twice. Now read once into a local.

3. The class map mishandled __proto__

Building the map with list[key] = value and Object.assign uses [[Set]], which hits the __proto__ setter and reassigns the prototype instead of defining an own property. class="__proto__", inactiveClass="__proto__", and classList={{["__proto__"]: true}} all silently lost the class.

Reverted to the original object-literal form, which uses [[DefineOwnProperty]]. This cost nothing measurable, so it was never worth the risk.

4. The fast path check missed non-enumerable own props

for...in skips non-enumerable own properties, but splitProps still forwards them, so createComponent(A, props) with a non-enumerable id silently dropped it. Now uses Object.getOwnPropertyNames, which is the conservative direction: anything unrecognized falls back to the spread.

Also changed

Reverted the link?: booleanboolean | string widening. Widening a global anchor attribute type just to preserve exact serialization was not a good trade, so the fast path emits bare link. The router only ever does hasAttribute("link"). This is the one remaining output difference from main, and it also drops ~7 KB from a 1000-link page.

Tests no longer assert exact whitespace or the link representation, and the navigation test uses vi.waitFor instead of a fixed delay.

Verification

  • 30 differential SSR cases (the original 22 plus 8 adversarial ones) now render identically to main, modulo the link marker form.
  • Client DOM output identical to main, including the dynamic-spread case.
  • Suite: 275 DOM tests, 18 SSR tests, types clean.
  • The 9 new regression tests fail on the previous commit; all 18 SSR tests and all 16 DOM tests pass unmodified against main.

Benchmark, three alternating build rounds to control for drift:

round main this branch speedup
1 5.90 ms 1.11 ms 5.32x
2 5.93 ms 1.13 ms 5.25x
3 5.81 ms 1.13 ms 5.14x

Client bundle, clean-room Vite production build importing A, Router, Route:

main this branch
raw 43663 B 42151 B
gzipped 13485 B 13081 B

I confirmed the server branch is fully constant folded: the client bundle contains a single <a> template and no trace of the fast path check.

Known remaining headroom, not in this PR

The fast path only helps links with no extra props. A link with any extra prop still costs ~3.9 ms per 1000, since it pays both splitProps and the spread's mergeProps. Building a single server anchor-props object and emitting one spread gets that to ~1.1 ms, and a descriptor-preserving variant to ~1.4 ms. It needs careful precedence handling for children, innerHTML, classList, link, and aria-current, so I have left it out rather than grow this PR. Happy to follow up if you want it.

Two smaller ones I measured and rejected: bypassing the three server createMemos via direct route resolution is worth 4-5% and not worth the extra branching, and replacing split(/[?#]/) with search/slice made no measurable difference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

<A> costs ~6us of server CPU per instance during SSR (20x a plain <a>), mostly mergeProps/splitProps

1 participant