Repository navigation
useHead: <!--<script> in a JSON-LD body swallows the SSR markup after it #4011
Description
Describe the bug
On the server, useHead writes an inline script body with only </script rewritten (server.ts#L1536-L1537). For raw text that is not enough: <!-- followed by <script puts the HTML tokenizer in the script data double escaped state, and the script's own </script> no longer closes it. The element runs on through the markup that follows until the tokenizer accepts a later </script>, which takes a --> somewhere in between (Solid's <!--$--> markers count) and then another inline script. In the repro below nothing closes it, so </head> and the whole body end up as script text. On a page with an inline script further down, the cut stops there and that script is lost too.
A CMS title or description containing <!--<script> is enough to trigger this, since JSON.stringify leaves < as is.
There is a smaller hydration problem next to it. When the body contains </script, the server writes <\/script, so the element's textContent no longer equals children. headElementMatches compares the two as is (client.ts#L2109-L2110), misses, and the client removes the server element and appends a new one. The same mismatch happens for classic and module scripts whose body contains </script, and there the replacement runs a classic script a second time, since a script created with createElement executes when inserted. The change proposed below covers JSON types only.
Your Example Website or App
No hosted example. I ran the repro below against next (84dada7) with the server test setup in packages/web; the first test of test/server/use-head-json-script.spec.tsx in my PR covers the same case.
Steps to Reproduce the Bug or Issue
import { renderToString, useHead } from "@solidjs/web";
function Seo(props: { name: string }) {
useHead({
tag: "script",
key: "ld",
props: { type: "application/ld+json", children: JSON.stringify({ name: props.name }) }
});
return null;
}
const html = renderToString(() => (
<html>
<head />
<body>
<Seo name="<!--<script>" />
<main>content</main>
</body>
</html>
));Output:
<html _hk=0><head><script type="application/ld+json" data-dh="script:key:ld">{"name":"<!--<script>"}</script></head><body><!--$--><!--/--><main>content</main></body></html>Parsed with jsdom (parse5), document.querySelector("main") is null and the script's textContent is:
{"name":"<!--<script>"}</script></head><body><!--$--><!--/--><main>content</main></body></html>
With name="</script>" the document parses fine, but the script text is {"name":"<\/script>"} while children is {"name":"</script>"}, so the client replaces the element when it hydrates.
Expected behavior
The JSON stays inside its script, the body renders, and the client adopts the server's element.
Platform
- OS: macOS
- Browser: reproduced with jsdom (parse5), not tested in a real browser
- Version: @solidjs/web 2.0.0-rc.14 and
nextat 84dada7
Additional context
What I'd propose. I opened a PR with this change and tests, and I'm happy to adjust it if you'd rather go another way:
For scripts whose body is JSON, write every < as \u003C. That means importmap and speculationrules, matched the way HTML matches script types, and the JSON MIME types as MIME Sniffing defines them: essence application/json or text/json, or a +json subtype. In valid JSON a < can only sit inside a string, where \u003C decodes to the same character, so JSON.parse gives back the same value. The package already does this for its own payloads (JSON.stringify(ops).replace(/</g, "\\u003C") in server.ts), and the Next.js JSON-LD guide recommends the same replace. Other data blocks, text/plain for example, keep their body as is.
The same helper would run on the streamed head patch ops and on the client, in createHeadElement and headElementMatches. Every element the registry manages then has one text form, and hydration claims the server element. On the server, useHead would install the escape, since it is the only way tags reach the head registry; the server: renderToString size floor then does not grow in my measurement, and only bundles that import useHead pay for it.
Classic and module scripts would keep the current </script rewrite. They can hit the same <!--<script> problem, but replacing < in JavaScript changes its meaning outside string literals, and the HTML spec's advice is to escape those sequences inside literals, which the runtime can't do on arbitrary code. The hydration mismatch for those scripts would also stay. I'd treat both separately, but happy to hear otherwise.
Related: #1665 asked about a JSX <script type="application/json"> in Solid 1, and the suggestion there was the innerHTML prop. useHead reads the body from children only (an innerHTML prop is emitted as a plain attribute), so today the caller has to escape < before passing JSON in.
Activity
ryansolid commentedon Oct 13, 2026
Generated by Claude Code