Version: 2.0.0-rc.8 (@solidjs/web)
Repro
import { renderToString, ssrElement, createComponent } from "@solidjs/web";
import { createMemo } from "solid-js";
const Item = p => createMemo(() => ssrElement("li", {}, () => p.i, true));
renderToString(() => [1, 2, 3].map(i => createComponent(Item, { i })), { noScripts: true });
// <li _hk=00 >1</li><!--!$--><li _hk=10 >2</li><!--!$--><li _hk=20 >3</li>
renderToString(() => [1, 2, 3].map(i => ssrElement("li", {}, () => i, true)), { noScripts: true });
// <li _hk=0 >1</li><li _hk=1 >2</li><li _hk=2 >3</li>
Any component whose return value is a memo/accessor (Dynamic, Show, createMemo(() => <el/>), most wrapper libraries) pays 8 bytes per adjacent instance and an extra comment node the client has to skip in stripTextSeparators. For a 1000-item list that's 8 KB of markup and 1000 comment nodes that exist only to separate text nodes that don't exist.
Cause
packages/web/src/server.ts, tryResolveString (~4456) and resolveSSRNode (~4515): the separator decision is made on the unresolved item — itemNonObj = item !== null && typeof item !== "object" — so functions count as "non-object" (text-like) and two adjacent functions get a separator regardless of what they return.
Expected
The separator is only needed between two adjacent text nodes (so the client can split them). Resolve the item first and decide on the resolved value: string/number → text-like; { t } / element markup → not.
Notes
tryResolveString has no top guard (unlike resolveSSRNode), so the root array of renderToString gets separators too.
- Client side
stripTextSeparators / claimInitial cost scales with these; see the hydrate numbers in the perf tracker.
- Surfaced by the
@yak/solid audit (DigitecGalaxus/next-yak#644).
Version: 2.0.0-rc.8 (
@solidjs/web)Repro
Any component whose return value is a memo/accessor (
Dynamic,Show,createMemo(() => <el/>), most wrapper libraries) pays 8 bytes per adjacent instance and an extra comment node the client has to skip instripTextSeparators. For a 1000-item list that's 8 KB of markup and 1000 comment nodes that exist only to separate text nodes that don't exist.Cause
packages/web/src/server.ts,tryResolveString(~4456) andresolveSSRNode(~4515): the separator decision is made on the unresolved item —itemNonObj = item !== null && typeof item !== "object"— so functions count as "non-object" (text-like) and two adjacent functions get a separator regardless of what they return.Expected
The separator is only needed between two adjacent text nodes (so the client can split them). Resolve the item first and decide on the resolved value: string/number → text-like;
{ t }/ element markup → not.Notes
tryResolveStringhas notopguard (unlikeresolveSSRNode), so the root array ofrenderToStringgets separators too.stripTextSeparators/claimInitialcost scales with these; see the hydrate numbers in the perf tracker.@yak/solidaudit (DigitecGalaxus/next-yak#644).