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. — landed in perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves #3454 (merge/omit always return O(1) lazy views; an omit over a merge flattens to filtered leaf entries; omit(props, key => …) predicate form; truthful getOwnPropertyDescriptor through every layer), chore(solid,web,universal): the runtimes' seams move behind solid-js/internal #3470 (the view protocol consumers walk moves behind solid-js/internal), and the follow-up perf/view-table-on-enumeration (in review): a view's resolved key table is built by enumeration or after 16 reads, never on the first read, and ssrElement walks a view's entries instead — profiled on the Kobalte-shaped chain, a third of SSR time was the table code and its garbage. Same-process A/B: SSR chain 8.2× → 5.8× the compiled floor. Original design note follows. 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). — the styled-element shape closed with perf(web): spread() creates fewer reactive nodes and accepts a sources array #3419 (1.47× behind yak's path → 1.00×), and yak reports the whole PR's hydrate on rc.8 "inside the floor" with rc.8's spread fast path alone worth ~12 points to their runtime. The composition cases (tabs, multifile: 0.45× React on rc.8's predecessor) have not been re-measured since; re-run on rc.9 before profiling further.
Cheap "props plus one key" for the theme case (merge(props, { theme }) allocates a merge object per instance; Object.create(props) was slower). — since perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves #3454 a merge() is one record + one Proxy with no descriptor copy, the same shape as the shared-handler view yak settled on (withTheme / viewTraps, 279317a6); needs the harness on rc.9 to confirm it is within noise before closing.
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) — landed asdynamic(source, { static })+isStatic(o, key)in perf(signals,web): dynamic(source, { static }) and isStatic(o, key) #3471: no memo per instance, the tag goes straight tossrElement/ the compiled element path, and a library decides per instance from the prop's descriptor (isStaticsees through merge/omit layers), so a literalas="a"at the call site takes the static path while a reactiveaskeeps the memo.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, part 2 (namespace:dynamic()honorsxmlns) in fix(web): dynamic() honors xmlns when creating a tag-name element (#3386) #3436;<Dynamic>deprecated in favor ofdynamic(). The compiler-lowered<element tag>(Static-tag intrinsic:<element tag={tag}>for elements whose tag is fixed at definition time #3429) stays open as a later question — the runtime path is fast enough that it is a spread-shape question, not a memo one.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. — landed in perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves #3454 (merge/omitalways return O(1) lazy views; an omit over a merge flattens to filtered leaf entries;omit(props, key => …)predicate form; truthfulgetOwnPropertyDescriptorthrough every layer), chore(solid,web,universal): the runtimes' seams move behind solid-js/internal #3470 (the view protocol consumers walk moves behindsolid-js/internal), and the follow-upperf/view-table-on-enumeration(in review): a view's resolved key table is built by enumeration or after 16 reads, never on the first read, andssrElementwalks a view's entries instead — profiled on the Kobalte-shaped chain, a third of SSR time was the table code and its garbage. Same-process A/B: SSR chain 8.2× → 5.8× the compiled floor. Original design note follows. 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). — the styled-element shape closed with perf(web): spread() creates fewer reactive nodes and accepts a sources array #3419 (1.47× behind yak's path → 1.00×), and yak reports the whole PR's hydrate on rc.8 "inside the floor" with rc.8's spread fast path alone worth ~12 points to their runtime. The composition cases (tabs, multifile: 0.45× React on rc.8's predecessor) have not been re-measured since; re-run on rc.9 before profiling further.merge(props, { theme })allocates a merge object per instance;Object.create(props)was slower). — since perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves #3454 amerge()is one record + one Proxy with no descriptor copy, the same shape as the shared-handler view yak settled on (withTheme/viewTraps, 279317a6); needs the harness on rc.9 to confirm it is within noise before closing.@yak/solidto delete the duplicate. — blocked on rc.9 (Release RC packages (rc) #3399). Faster @yak/solid runtime for unfolded styled components DigitecGalaxus/next-yak#644 merged 2026-09-15 on rc.8, so none of perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves #3454 / perf(signals,web): dynamic(source, { static }) and isStatic(o, key) #3471 / the table follow-up is in what it measures. Its final experiment table (rc.8 vs rc.6) is the list to re-run: see the status comment below.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.