Skip to content

fix(web): decide SSR text separators on resolved values - #3394

Merged
ryansolid merged 3 commits into
nextfrom
fix/ssr-text-separators
Sep 13, 2026
Merged

ryansolid merged 3 commits into
nextfrom
fix/ssr-text-separators

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3383.

Problem

resolveSSRNode and tryResolveString emitted <!--!$--> between two adjacent array items whenever both had a non-object static type — which counted every function. Two neighbouring memos or components were separated as if they were text even when both produced elements:

const Item = p => createMemo(() => ssrElement("li", {}, () => p.i, true));
renderToString(() => [1, 2, 3].map(i => createComponent(Item, { i })), { noScripts: true });
// before: <li _hk=00>1</li><!--!$--><li _hk=10>2</li><!--!$--><li _hk=20>3</li>
// after:  <li _hk=00>1</li><li _hk=10>2</li><li _hk=20>3</li>

Every Dynamic, Show, or wrapper-library instance in a list paid eight bytes and a comment node, and the client node.remove()d each one during stripTextSeparators.

Fix

The client claims a multi-insert's nodes positionally after flattening the value (memos resolved, nested arrays spliced, nullish dropped), so what matters is what each item resolves to: two texts must land in distinct text nodes; elements and template markup never need a separator. The walkers now track "last appended was text" (ssrTextTail), maintained at the leaves — arrays and functions pass it through, an unresolved async hole still counts as text on both sides — and reset it per independent region (root resolve, template hole, element children) via the exported wrappers. No function is invoked more than before; hydration id order is unchanged. The top rule for ssrElement's direct children is preserved as-is.

Deciding on the resolved value also separates text the old rule merged, both latent hydration mismatches:

  • across a dropped nullish/boolean item: [7, null, "x"] rendered 7x (one node)
  • across a nested array boundary: ["a", ["b"]] rendered ab

In both, the second text item claimed nothing and materialized a client node on the first update.

Tests

  • test/server/ssr-text-separators.spec.tsx — 8 cases (element lists, Dynamic lists, text/text, null/undefined/false/true gaps, nested arrays, mixed memo results, 200-row list asserting zero separators).
  • Parity harness (scenarios.tsx, real compiler on both sides, streamed artifact → jsdom hydrate → update pass): five separator-* scenarios and two new invariants — adoptAll (every node in the container is a server node after hydration and after the update pass; a failed text claim reads correctly via textContent until the update) and noSeparators.
  • Updated one expectation in escape-late-values.spec.tsx that had encoded the merged 7x output.

Against the previous walker, same scenarios: the server side fails noSeparators for both element lists, and the hydrate side reports client-created text "x" after update (text-after-null) and client-created text "b" after update (nested-array-text). Two existing artifacts lose a spurious leading separator (a client-only component's undefined return counted as text).

@solidjs/web dom 77/734, server 85/804, hydrate 28/173 green. Neither compiler emits the separator; runtime-only. Independent of #3393 (whitespace-tolerant assertions where the two touch).

Surfaced by the @yak/solid audit (DigitecGalaxus/next-yak#644): the base runtime rendered every styled element through Dynamic, so every list carried one of these per element.


Authored with Claude via Cursor.

@changeset-bot

changeset-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 98f9d88

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

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal 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

@codspeed

codspeed Bot commented Sep 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 160 untouched benchmarks


Comparing fix/ssr-text-separators (92715ad) with next (5b31076)

Open in CodSpeed

@coveralls

coveralls commented Sep 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34729776142

Coverage remained the same at 71.842%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 519
Branch Coverage: 65.7%
Branches in Coverage %: Yes
Coverage Strength: 15.01 hits per line

💛 - Coveralls

ryansolid and others added 2 commits September 12, 2026 18:05
`resolveSSRNode` and `tryResolveString` emitted `<!--!$-->` between two
adjacent array items whenever both had a non-object static type — which
counted every function. Two neighbouring memos or components were
separated as if they were text even when both produced elements, so a
list of `Dynamic`, `Show`, or wrapper-library instances paid eight bytes
and a comment node per item, and the client removed each one on hydrate.

The client claims a multi-insert's nodes positionally after flattening
its value, so what matters is what each item RESOLVES to: two texts must
land in distinct text nodes; elements and template markup never need a
separator. Track "last appended was text" through the walk (`ssrTextTail`,
maintained at the leaves; arrays and functions pass it through; an
unresolved async hole still counts as text on both sides) and reset it
per independent region via the exported wrappers.

Deciding on the resolved value also separates text the old rule merged:
across a dropped nullish/boolean item (`[7, null, "x"]` → `7x`, one node)
and across a nested array boundary (`["a", ["b"]]` → `ab`). Both were
latent hydration mismatches — the second text item claimed nothing and
materialized a client node on the first update.

Parity harness: five scenarios covering the lists, the null gap, nested
arrays, and mixed memo results, plus two invariants — `adoptAll` (every
node is a server node after hydration AND after the update pass; a failed
text claim is invisible to textContent until the update) and
`noSeparators`. Against the previous walker, the server side emits
separators for both element lists and the hydrate side reports
`client-created text "x"` / `"b"` after update.

Co-authored-by: Cursor <cursoragent@cursor.com>
test-types failed on next's Component type (SolidElement return): the
#3383 fixtures return memos, valid children at runtime but not in the
type. Cast at the three sites, JSX imported from @solidjs/web.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the fix/ssr-text-separators branch from 98280fc to 92715ad Compare September 13, 2026 01:06
…#3393

#3393 removed the trailing space before '>' on emitted tags; this
scenario was recorded before it landed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 084e621 into next Sep 13, 2026
7 of 8 checks passed
ryansolid added a commit that referenced this pull request Sep 14, 2026
`Show`'s `fallback` prop is typed as an element; the zero-arg thunk is a
runtime-accepted shape the scenario exercises on purpose, so cast it (the
same treatment as the memo-returning components in #3394).

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 14, 2026
…client's flatten computed (#3414) (#3428)

* fix(solid): resolve SSR boundary children in a scope mirroring the client's flatten computed (#3414)

A server-rendered `<Errored>` fallback hydrated dead when the boundary's
children threw synchronously and the fallback was a zero-arg thunk
(`fallback={() => <Fallback />}`) inside an enclosing boundary. Both sides
hand the thunk back unresolved and the enclosing boundary unwraps it — the
client inside its second computed (`boundaryComputed(() => flatten(read(c)))`,
the boundary owner's child `…1`), the server inline under the boundary owner
(`ctx.resolve` right after the `fn` owner `…0`) — so the fallback's root
element took `…1` on the server and `…10` on the client, failed its claim,
and its handlers and effects never attached. Same misalignment for `Loading`
as the consumer, fragment children, and `Show`'s fallback thunk; an element
hole in between (compiled `scope()` ↔ transparent insert effect) was already
aligned.

The server `Errored` and `Loading` boundaries now resolve their children's
result in a virtual id scope mirroring that second computed — ssrScope's
technique: the owner keeps its identity (retry wraps capture it and read the
current pull's error handler off it; a real child owner snapshots a stale
handler and double-renders the fallback) and only its id counter is
rewritten for the resolve, retries continuing where the discovery pass left
it. The error record is serialized at the boundary's own id, read once,
since `owner.id` is rewritten while the error lands.

Parity-harness scenarios cover Errored/Loading/fragment consumers and the
Show producer, plus the element-hole control.

Closes #3414

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): type the Show fallback thunk scenario as an element

`Show`'s `fallback` prop is typed as an element; the zero-arg thunk is a
runtime-accepted shape the scenario exercises on purpose, so cast it (the
same treatment as the memo-returning components in #3394).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 14, 2026
#3394 split `tryResolveString` and `resolveSSRNode` into an entry function
that resets `ssrTextTail` and a recursive body. The bodies are too big for
V8 to inline, so every template hole paid one extra call — about 2% of SSR
throughput on element-heavy pages, up to 7% where holes are dense.

Pass a `nested` flag through the recursion instead: entry calls (no flag)
reset the separator state, recursive calls keep it. One function each,
identical output. `server-mock.ts` mirrors the signature.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants