perf(A): cut server render cost by ~5x - #584
Conversation
🦋 Changeset detectedLatest commit: 9f9f852 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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.
e9faea6 to
9f9f852
Compare
|
Pushed a revision. An adversarial review of the first version turned up four behavior differences from 1.
|
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?: boolean → boolean | 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 thelinkmarker 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.
Fixes #583.
<A>spent most of its server render time inmergePropsandsplitProps, building accessor-backed prop objects that a one shotrenderToStringcan 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
mergePropsthat existed only to defaultactiveClass/inactiveClass, and read the defaults with??at the point of use. Same semantics, sincemergePropsalso only falls back onundefined, and the reads stay inside the reactive JSX expression.location.pathnameacross links. Every<A>on a page rannormalizePath+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.JSON.stringifywhen there is nostate.JSON.stringify(undefined)returnsundefined, so the attribute was already omitted.classListin one object instead of up to three spreads.<A>consumes itself, skipsplitPropsand the JSX spread entirely. This is the largest single win: the JSX spread compiles to anothermergePropsper link.The last one is gated on
isServerdeliberately. 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, customactiveClass/inactiveClass, userclass, userclassList,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 tomain, except for one insignificant space inside the tag on the fast path,link="true">instead oflink="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,classListmerging, state serialization, prop forwarding, children, and active class updates across navigation. All 15 also pass against unmodifiedmain, 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 againstmain; 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 isvitest.ssr.config.tsand atest:ssrscript, wired intopnpm test.Full suite: 274 DOM tests, 10 SSR tests,
test:typesclean.Size. Client bundles get slightly smaller, since the server branch folds away and
Ano longer pulls inmergeProps. Vite production build importingA,Router,Route:Benchmark
Reproduction is in #583. Node 26.3.0, Apple silicon, 1000 links, 200 renders after 30 warmup:
Profile before:
mergeProps24%,splitProps/split16%, 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
linkin the JSX attribute augmentation is widened frombooleantoboolean | string, needed to writelink="true"on the fast path so its serialization matches what the spread path produces.childrenis now in thesplitPropskey list and rendered explicitly, so both paths handle it the same way. Laziness is preserved, the compiler wrapsprops.childrenin a memo.