Skip to content

fix(ssr): escape style/script children under svg and math (#4704) - #4722

Merged
ryansolid merged 2 commits into
nextfrom
claude/project-thread-c5xes4-4704
Nov 8, 2026
Merged

ryansolid merged 2 commits into
nextfrom
claude/project-thread-c5xes4-4704

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #4704.

Summary

In HTML, <style> and <script> are raw text. Under <svg> and <math> they are ordinary elements, and their text parses as markup and character references. SSR used to write every <style>/<script> child raw no matter where it sat. So on next, a text hole like <math><style>{x}</style></math>, or the same thing under <svg>, could close the element and render the rest of x as markup. 1.x had already fixed the SVG case (dom-expressions#590), but that fix never came over to 2.0.

Now both compilers render raw text only for HTML-namespace <style>/<script>, and they escape in foreign content. The nearest JSX ancestor that settles the namespace decides:

  • Foreign: <svg>, <math>, any SVG or MathML element (so a <g> partial counts), and a static xmlns.
  • HTML again: SVG's HTML integration points (foreignObject, desc, title), MathML's text integration points (mi, mo, mn, ms, mtext), annotation-xml with an HTML encoding, and the tags that break out of foreign content (div, p, span, …).
  • Components are transparent, because their children usually render in place. So <svg><Show>…<style> escapes.
  • No deciding ancestor: stays HTML. For example, a component whose root is <style> stays raw, as it is today.

Babel reads the namespace in preprocess, because an outer template is lowered before the JSX inside its expressions. This keeps <svg>{show && <style>{x}</style>}</svg> correct. The Oxc compiler runs the same pass (ssr/foreign.rs) before lowering. Both cover the template path and the spread (ssrElement) path.

DOM output is unchanged. Static DOM templates on next already escape correctly, and dynamic children are text nodes.

Size: this is a compile-time change only, so no runtime bytes are affected on client or server.

Rules check: this is a compiler escaping fix. It touches no reactive, async or hydration semantics in RULES-FUNDAMENTALS/RULES-DERIVED, so there are no conflicts.

How did you test this change?

  • New cases appended to the Babel SVG SSR fixture: svg, math, a <g> partial, through an expression, through <Show>, foreignObject, annotation-xml encoding="text/html", and HTML <style> staying raw. The Oxc compiler's SVG fixture is regenerated from the same source, and its output matches Babel.
  • packages/web/test/server/foreign-raw-text-4704.spec.tsx covers the issue's repros through renderToString, and it passes with both JSX_COMPILER=babel and the native compiler. It fails on next.
  • babel-plugin: typecheck and vitest run (422 passed).
  • compiler: cargo test, cargo clippy (clean), and vitest run (6894 passed).

Generated by Claude Code

)

In HTML, <style> and <script> are raw text. In SVG and MathML content
they are ordinary elements whose text parses as markup and character
references, so SSR now escapes their children there like any other
text. The namespace is the nearest JSX ancestor that settles it:
<svg>/<math>, SVG/MathML elements and xmlns are foreign; HTML
integration points (foreignObject, desc, title, the MathML text
integration points, annotation-xml with an HTML encoding) and breakout
tags are HTML. Components are transparent, and an element with nothing
deciding stays HTML. Babel reads it in preprocess while the JSX tree is
whole; the Oxc compiler runs the same pass before lowering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
@changeset-bot

changeset-bot Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d934522

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

This PR includes changesets to release 15 packages
Name Type
@solidjs/babel-plugin Patch
@solidjs/compiler 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

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.42 KB 0 B 0 B −136 B 7.43 KB ✅
signals: + createStore 15.11 KB 0 B 0 B −21 B 15.13 KB ✅
signals: + isPending/latest 12.75 KB 0 B 0 B +3 B 12.76 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.10 KB 0 B 0 B −21 B 8.12 KB ✅
app: render + one signal (the simple-app floor) 9.79 KB 0 B 0 B −195 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.05 KB 0 B 0 B −45 B 19.05 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 33.87 KB 0 B 0 B +3 B 33.83 KB ⚠️ over by 42 B, 17 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.73 KB 0 B 0 B −45 B 13.73 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.45 KB 0 B 0 B −45 B 15.50 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.08 KB 0 B 0 B −45 B 30.12 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.97 KB 0 B 0 B −247 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.73 KB 0 B 0 B −35 B 26.75 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.92 KB 0 B 0 B −35 B 33.00 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.09 KB 0 B 0 B −32 B 34.11 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.28 KB, wire.js 3.48 KB
page: live server components (base + live/GET + action + isPending/latest) 40.73 KB 0 B 0 B +165 B 40.76 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.29 KB, wire.js 3.48 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 36.04 KB 0 B 0 B −46 B 36.00 KB ⚠️ over by 36 B, 66 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.29 KB, wire.js 3.48 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 44.83 KB 0 B 0 B −11 B 44.85 KB ✅ eager (counted): web.js 23.67 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.08 KB, sc-comments.js 0.19 KB, trace.js 9.28 KB, wire.js 3.48 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.56 KB 0 B 0 B −120 B 41.60 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.25 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 50.58 KB 0 B 0 B +3 B 50.57 KB ⚠️ over by 10 B, 17 B minified headroom eager (counted): client.js 28.84 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.31 KB, trace.js 9.27 KB, wire.js 3.50 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.90 KB 0 B 0 B +13 B 22.92 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: hydrating + every store primitive family: over brotli cap by 42 B; minified 101,921 B vs 101,918 B recorded with the cap (+3 B) — 17 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 36 B; minified 108,500 B vs 108,546 B recorded with the cap (−46 B) — 66 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 10 B; minified 148,802 B vs 148,799 B recorded with the cap (+3 B) — 17 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

Copy link
Copy Markdown
Member Author

Run benchmarks (signals) failed in the CodSpeed action's setup step, before any benchmark ran. The CodSpeed CLI download is timing out (504) across PRs right now, and #4718 hit the same failure on every bench job. This PR only changes compiler output for <style>/<script> under svg/math and doesn't touch signals, so the failure isn't from this diff. No fix exists on our side. The bench jobs need a re-run once CodSpeed serves its installer again.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

The job check failed only in the Coveralls reporter install step, after every test suite had passed. The suites were signals, web, babel-plugin (422) and compiler (6745 passed, 14 expected fail). The step reported Checksum verification failed (Linux) on the downloaded reporter tarball. #4718 hit the same step at the same time, and on its re-run the step got a 504. So this is the coverage upload's infrastructure, not this diff. No fix exists on our side.


Generated by Claude Code

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 42470018619

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit 3e1ef0f on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 78.945%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1372
Covered Lines: 1143
Line Coverage: 83.31%
Relevant Branches: 1112
Covered Branches: 818
Branch Coverage: 73.56%
Branches in Coverage %: Yes
Coverage Strength: 30.33 hits per line

💛 - Coveralls

@ryansolid
ryansolid merged commit 701cbb3 into next Nov 8, 2026
14 of 18 checks passed
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.

3 participants