You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
DigitecGalaxus/next-yak#644 rewrote the @yak/solidstyled() runtime and made it 12× faster on SSR and ~2× on hydrate/mount over its own base. Reading the PR against our code, almost every piece of the rewrite is a reimplementation of a Solid primitive that was too slow or too generic to use directly:
per-instance memo for a constant tag; no static class in the template; no runHydrationEvents; tag-only namespace (#3386)
shared withTheme proxy
merge(props, { theme })
one merge object per instance is measurable
once() on the server
createMemo
server memos are real nodes held until the deferred dispose (#3385)
This issue tracks closing those gaps so a library can call Solid and get the same numbers. Baseline harness: ryansolid/yak-bench — the 14 css-in-js-bench workloads, @yak/solid at the PR's base and head, both on 2.0.0-rc.8, with next-yak/React as the reference lane. Acceptance for every item below: a variant of the yak runtime using our primitive lands within noise of the hand-rolled one in that harness.
Where the remaining time goes
Solid 2.0.0-rc.8, @yak/solid at PR head. Full tables: SSR · Chromium.
Static styled intrinsics are fast (SSR 25× React, hydrate/mount 2–3.5× React). The cases still behind React are the ones that go through our generic paths — styled(Component) with {...rest} spreads, dynamic $props, imported primitives:
case
SSR PR/React
hydrate PR/React
mount PR/React
tabs (styled(Component) + spread)
1.10×
0.45×
0.63×
multifile-composition
1.05×
0.46×
0.64×
product-grid (400 tiles × 11 el)
4.15×
0.46×
0.81×
realistic-button
6.10×
0.72×
1.02×
dyn-fair (CSS var per element)
10.06×
0.81×
1.04×
btn-variant vs compose-1 (dynamic vs static props, same element)
Server merge/omit fast path when every source is a plain object (the rc.8 spread fast path 4e730a9, but on the server), and a predicate / $-prefix form of omit so "drop all $ keys" doesn't require enumerating first. — in design. CPU profile of the tabs SSR case (styled(Component) + omit + spread): merge 18%, omit 10%, yak's copyProps 11%, GC 16% (mostly from the same allocations); Solid's rendering proper (ssrElement, escape, resolveSSRNode, ssr) ~7%; component machinery ~2%. Each Tab builds a chain of three or four Proxies and every consumer enumerates through the traps. Direction: omit carries its skip-set on the $SOURCES brand and ssrElement/spread/merge walk branded views directly, never through traps; the Proxy stays only for direct property reads.
ssrElement with skip rules / extra sources, or a lower-level serializer, so a library doesn't have to materialize getter-bearing props just to have them walked again (yak measured −18.9% HTTP throughput on that path). — landed in feat(web): ssrElement accepts an array of prop sources and a skip predicate #3418: ssrElement(tag, sources[], children, needsId, skip?), single walk, later wins, winner read once. On dynamic-prop shapes ssrElement is now within 3–23% of yak's serializeElement (was 2–3× behind); the fully static shapes remain 3× behind because yak string-concats those at build time (not our gap).
Hydration per-element overhead: hydrate is ~1.7× mount for 1000 static buttons (15.7 vs 9.3 ms at 4× throttle). Profile gatherHydratable / registry build and per-element getNextElement, and stripTextSeparators + [...childNodes] in claimInitial on large flat lists (interacts with SSR emits <!--!$--> between adjacent memo/function items even when they resolve to elements #3383 separators). — profiling in progress (Chromium CPU profile of hydrate on tabs / product-grid, the 0.45× cases).
Cheap "props plus one key" for the theme case (merge(props, { theme }) allocates a merge object per instance; Object.create(props) was slower).
Re-run the harness after each item (gated on an rc.9 so yak targets published packages); when a primitive is within noise of yak's version, open the corresponding follow-up on @yak/solid to delete the duplicate.
Not our gap
Definition-time chain flattening, static-class collection, choosing the renderer when the module loads, caching template parts: that is yak doing at runtime what our compiler does at build time. No Solid change removes it.
Landed alongside (correctness surfaced by the same audit)
Versus React (the part that matters more than parity with yak's PR)
Element-dense cases: Solid 1.5–4× faster than React on SSR, hydrate and mount. Component-composition pages (tabs, multifile-composition): React ~4× faster on SSR and ~2× on hydrate. The SSR half is the props-plumbing item above, not Solid's renderer; the hydrate half is being profiled.
DigitecGalaxus/next-yak#644 rewrote the
@yak/solidstyled()runtime and made it 12× faster on SSR and ~2× on hydrate/mount over its own base. Reading the PR against our code, almost every piece of the rewrite is a reimplementation of a Solid primitive that was too slow or too generic to use directly:serializeElementssrElementstyle=""/class=""(#3382)copyProps/proxyPropsmerge/omit$PROXY;omithas no predicate/prefix form; plain path leaks$SOURCES(#3384)createElementRenderer(template +getNextElement+spread+insert)Dynamic/dynamic()runHydrationEvents; tag-only namespace (#3386)withThemeproxymerge(props, { theme })once()on the servercreateMemoThis issue tracks closing those gaps so a library can call Solid and get the same numbers. Baseline harness: ryansolid/yak-bench — the 14
css-in-js-benchworkloads,@yak/solidat the PR's base and head, both on2.0.0-rc.8, withnext-yak/React as the reference lane. Acceptance for every item below: a variant of the yak runtime using our primitive lands within noise of the hand-rolled one in that harness.Where the remaining time goes
Solid 2.0.0-rc.8,
@yak/solidat PR head. Full tables: SSR · Chromium.Static styled intrinsics are fast (SSR 25× React, hydrate/mount 2–3.5× React). The cases still behind React are the ones that go through our generic paths —
styled(Component)with{...rest}spreads, dynamic$props, imported primitives:Checklist
spread()→ one render effect per element (client.ts~809, the// TODO: make this better). Three reactive nodes per element today; the dynamic-props path costs 2× the static one on hydrate and mount, andspreadis the largest share. (spread()creates three reactive nodes per element #3388) — landed in perf(web): spread() creates fewer reactive nodes and accepts a sources array #3419:reffolded into the attribute effect, children stay owned (two nodes with children, one without), sources array; compilers emit the array form for element spreads (feat(compiler): emit the spread/ssrElement sources array for multi-source element spreads #3423, universal feat(universal): spread() with fewer reactive nodes and a sources array; compilers emit it #3424). Browser hydrate on the styled-element shape went from 1.47× behind yak's hand-rolled path to 1.00×.Dynamic/dynamic(): whensource()is a string that can't change, skip the factory + instance memos, allow a staticclassbaked into a cached template, and take the compiled-JSX element path. Same fix carriesrunHydrationEventsand namespace correctness. (Constant-tagDynamic/dynamic()should take the compiled-element path #3387,dynamic()/Dynamicwith a string tag: norunHydrationEvents(), namespace ignores parent #3386) — superseded by Static-tag intrinsic:<element tag={tag}>for elements whose tag is fixed at definition time #3429<element tag={tag}>: a compiler-lowered static-tag intrinsic (no memos, spreads and static attributes compiled,ssrElementsources on the server) rather than adynamic()fast path, which would keep amergeand a component boundary per element.dynamic()/Dynamicwith a string tag: norunHydrationEvents(), namespace ignores parent #3386 part 1 (runHydrationEvents) fixed in fix: run hydration events after dynamic() spreads a string tag #3396; namespace-from-parent is still open and<element tag>inherits it.merge/omitfast path when every source is a plain object (the rc.8spreadfast path 4e730a9, but on the server), and a predicate /$-prefix form ofomitso "drop all$keys" doesn't require enumerating first. — in design. CPU profile of the tabs SSR case (styled(Component) +omit+ spread):merge18%,omit10%, yak'scopyProps11%, GC 16% (mostly from the same allocations); Solid's rendering proper (ssrElement,escape,resolveSSRNode,ssr) ~7%; component machinery ~2%. EachTabbuilds a chain of three or four Proxies and every consumer enumerates through the traps. Direction:omitcarries its skip-set on the$SOURCESbrand andssrElement/spread/mergewalk branded views directly, never through traps; the Proxy stays only for direct property reads.ssrElementwith skip rules / extra sources, or a lower-level serializer, so a library doesn't have to materialize getter-bearing props just to have them walked again (yak measured −18.9% HTTP throughput on that path). — landed in feat(web): ssrElement accepts an array of prop sources and a skip predicate #3418:ssrElement(tag, sources[], children, needsId, skip?), single walk, later wins, winner read once. On dynamic-prop shapesssrElementis now within 3–23% of yak'sserializeElement(was 2–3× behind); the fully static shapes remain 3× behind because yak string-concats those at build time (not our gap).gatherHydratable/ registry build and per-elementgetNextElement, andstripTextSeparators+[...childNodes]inclaimInitialon large flat lists (interacts with SSR emits<!--!$-->between adjacent memo/function items even when they resolve to elements #3383 separators). — profiling in progress (Chromium CPU profile of hydrate on tabs / product-grid, the 0.45× cases).merge(props, { theme })allocates a merge object per instance;Object.create(props)was slower).@yak/solidto delete the duplicate.Not our gap
Definition-time chain flattening, static-class collection, choosing the renderer when the module loads, caching template parts: that is yak doing at runtime what our compiler does at build time. No Solid change removes it.
Landed alongside (correctness surfaced by the same audit)
<!--!$-->between adjacent memo/function items even when they resolve to elements #3383<!--!$-->separators decided on resolved values → fix(web): decide SSR text separators on resolved values #3394 (+ perf(web): fold the SSR walker entry wrappers into the walkers #3430 recovers the ~2% SSR cost of the walker split)ssrElementemitsstyle=""/class=""for nullish values #3382 emptystyle=""/class=""→ docs: correct createContext guidance for server rendering #3395merge()re-flattens through a stale$SOURCESand discards the object's own properties #3384merge()stale$SOURCES→ fix(signals): treat a plain merge() result as an ordinary source (#3384) #3401renderToStringdisposes its root viasetTimeout; graphs are retained across synchronous renders #3385renderToStringdeferred dispose → fix(web): dispose renderToString's root synchronously, committing the response head first (#3385) #3422dynamic()/Dynamicwith a string tag: norunHydrationEvents(), namespace ignores parent #3386 (1)runHydrationEventsafterdynamic()string tag → fix: run hydration events after dynamic() spreads a string tag #3396Versus React (the part that matters more than parity with yak's PR)
Element-dense cases: Solid 1.5–4× faster than React on SSR, hydrate and mount. Component-composition pages (tabs, multifile-composition): React ~4× faster on SSR and ~2× on hydrate. The SSR half is the props-plumbing item above, not Solid's renderer; the hydrate half is being profiled.