Skip to content

fix(compiler): escape string literals in SSR branches and returns - #4712

Open
lxsmnsyc wants to merge 2 commits into
solidjs:nextfrom
lxsmnsyc:fix/ssr-escape-literal-branches
Open

lxsmnsyc wants to merge 2 commits into
solidjs:nextfrom
lxsmnsyc:fix/ssr-escape-literal-branches

Conversation

@lxsmnsyc

@lxsmnsyc lxsmnsyc commented Nov 7, 2026

Copy link
Copy Markdown
Member

The server build now escapes string literals in a ternary branch, after &&, and in a function child's return. Before, the server rendered these literals as markup while the client inserted them as text. Fixes #4710.

Changes

  • escape_expression_in_place in packages/compiler/src/ssr/transform.rs escapes literals in conditional branches, the right side of &&, and IIFE returns.
  • Function returns escape literals too, except for a function value in an attribute. _$ssrAttribute stringifies that function's source.
  • A template literal with no expressions is escaped from its cooked value, so `a\nb` keeps its newline.
  • escapeExpression in packages/babel-plugin/src/ssr/element.ts gets the same change, so both compilers emit equal output.
  • New cases template78 to template83 in the conditionalExpressions SSR fixture.

Proof

Compiled with generate: "ssr" and rendered with renderToString from the workspace builds of @solidjs/web and solid-js:

<p>{open() ? "Close" : "<b>Open</b>"}</p>
hydratable: false hydratable: true
next (81a12d8) <p><b>Open</b></p> <p _hk=0><b>Open</b></p>
this branch <p>&lt;b>Open&lt;/b></p> <p _hk=0>&lt;b>Open&lt;/b></p>

The attribute case had the same gap. <p title={a() ? '"<b>' : 0} /> rendered title=""<b>" on next and renders title="&quot;&lt;b>" on this branch.

These cases render with one escape on both compilers, so there is no double escape:

  • a ternary child of a component that renders {props.children}
  • a ternary as a component prop, a fragment child, and a <Show> child or fallback
  • a ternary in style

Checks

  • packages/babel-plugin: pnpm test passes (414 tests).
  • packages/compiler: cargo test, cargo clippy -- -D warnings and vitest run pass. Under load, 4 parity tests timed out. They pass when run alone.
  • packages/web: pnpm test passes except 2 server specs that time out under load. They pass alone on this branch and on next.

Cost

  • No runtime cost. Literals are escaped at compile time.

Risk

  • Code that put markup in a ternary literal on purpose and relied on the server rendering it raw now gets text. The client already rendered it as text, so that code had a hydration mismatch.

Not done

  • Static quasis of a template literal with expressions in a text child, such as `<b>${x}</b>`, are still written raw on the server.
  • String operands of +, such as "<b>" + x, are still written raw on the server.
  • Both have the same mismatch with the client. They change which binary operands get escaped, so they are left for a separate PR.

🤖 Generated with Claude Code

- Escape string and template literals in a ternary branch, after `&&`,
  and in a function child's return at compile time in the server build.
- `_$ssr` and `_$ssrAttribute` write these values unescaped, so the server
  rendered markup where the client inserted text.
- A function value in an attribute keeps its source, because
  `_$ssrAttribute` stringifies it.
- Apply the same change to the Babel plugin to keep both compilers' output
  equal.

Fixes solidjs#4710

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78b19f4

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

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

@lxsmnsyc
lxsmnsyc marked this pull request as ready for review November 7, 2026 08:54
@codspeed

codspeed Bot commented Nov 7, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 16.52%

2 warnings

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 1 improved benchmark
✅ 200 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ arrow in template 12.7 ms 10.9 ms +16.52%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing lxsmnsyc:fix/ssr-escape-literal-branches (e1bdbb4) with next (8138dab)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Copy link
Copy Markdown
Member

Thanks for this. The ternary, && and function-child cases look right. I reproduced two gaps with both the Babel plugin and the Rust compiler:

  1. Function attribute with a branch is double-escaped. <div title={() => (c ? '"<' : "b")} /> now emits () => c ? "&quot;&lt;" : "b". Then ssrAttribute runs escape(String(fn), true) over that and produces &amp;quot;&amp;lt;, which no longer matches what the client's setAttribute stringifies. A direct () => '"<' is handled correctly, because the function-in-attribute exception (returnLiterals) applies there. That exception isn't passed down when recursing into ternary and && branches; those paths force true.
  2. Nested returns are still raw. Only top-level return statements are escaped. In {(() => { if (c) return "<i>"; return "<b>"; })()} the second literal is escaped and the first is emitted as raw markup. if (c) return x; with a non-literal x also emits no _$escape today, so a recursive walk over return statements would cover both.

Could you carry the attribute exception into the branch recursion and walk nested returns, and add fixtures for a function attribute containing a ternary and for a return inside if?


Generated by Claude Code

… returns

- A function value in an attribute keeps the literals in its ternary and
  `&&` branches as written. `ssrAttribute` stringifies the function's
  source and escapes it, so an escape at compile time escaped them twice.
- Escape every `return` of a function child or call, including one inside
  `if`, `switch`, `try`, a loop or a block. Nested functions stay as is.
- Replace the `escapeLiterals` flag with three modes (top, escape, keep) in
  both compilers, so the attribute exception carries through branches.
- Add fixtures for both cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lxsmnsyc

lxsmnsyc commented Nov 8, 2026 •

Copy link
Copy Markdown
Member Author

Thanks, both gaps are fixed in 78b19f4, in the Babel plugin and the Rust compiler.

  1. Function attribute with a branch. The escapeLiterals flag is now three modes: top, escape and keep. A function value in an attribute switches to keep, and keep carries into ternary branches, && and IIFE returns. <div title={() => (c ? '"<' : "b")} /> now emits () => c ? '"<' : "b" unchanged, and ssrAttribute escapes it once.
  2. Nested returns. Returns are now found at any depth: inside if/else, switch, try/catch/finally, loops, labels and blocks. Nested functions are left alone. if (c) return "<i>" emits "&lt;i>", and if (c) return x emits _$escape(x).

New fixtures in conditionalExpressions (template84 to template88) cover a function attribute with a ternary and with &&, a literal and a non-literal return inside if, and a switch with a nested arrow that stays unchanged. Both compilers produce the same output. The Babel plugin and compiler test suites pass locally.

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