Skip to content

fix(web): escape < in useHead JSON script bodies - #4012

Open
everton-dgn wants to merge 2 commits into
solidjs:nextfrom
everton-dgn:fix/head-json-script-escaping
Open

everton-dgn wants to merge 2 commits into
solidjs:nextfrom
everton-dgn:fix/head-json-script-escaping

Conversation

@everton-dgn

Copy link
Copy Markdown

Summary

useHead only rewrote </script in inline script bodies on the server. In a JSON-LD script, a value like <!--<script> moves the HTML tokenizer into the double-escaped state, so the closing </script> no longer ends the element and the markup after it becomes script text, up to a later </script> or the end of the document (in the issue's repro, the whole body).

Scripts whose body is JSON now get every < written as \u003C, which parses back to the same value. That covers importmap and speculationrules, matched the way HTML matches script types, and the JSON MIME types from MIME Sniffing (application/json, text/json, any +json subtype). Other data blocks keep their body. The helper, headBodyText in head.ts, runs wherever the registry produces or compares a body:

  • renderHeadTagMarkup (shell and resource markup) and flushHeadFragment (streamed ["a", ...] ops the client applies with textContent), through a hook the server useHead installs
  • createHeadElement and headElementMatches on the client

Since the client writes and compares the escaped text, hydration claims the server element. Before, a JSON body with </script went out as <\/script, never matched children, and the client swapped the element.

Classic and module scripts keep the existing </script rewrite, so a classic script with </script in its body still doesn't match on hydration and is still replaced; that needs its own fix. Client-created JSON scripts also hold the escaped text now: JSON.parse(el.textContent) returns the same value, but code reading the raw string would see \u003C.

Public API changes

None. No export, prop, option or type changes; headBodyText lives in the internal head.ts and is not re-exported. The only observable change is the text described above: JSON script bodies rendered by the server, and the textContent of JSON scripts the client creates.

Size

useHead is the only way tags reach the server head registry, so the escape is installed by useHead and a render that never imports it doesn't carry it. Against next @ 84dada7 every size scenario measures 0 B, the server: renderToString floor included (20,403 B brotli, 71,817 B minified), and no cap moves. Only bundles that import useHead pay for the escape.

How did you test this change?

New tests in packages/web:

  • test/server/use-head-json-script.spec.tsx renders <!--<script> in JSON-LD and parses the output with jsdom to check that the <main> after it survives. It pins the escaped markup for each JSON type (including ImPoRtMaP and Application/LD+JSON; charset=utf-8), checks that other data blocks keep their body (video/json, custom+json, importmap;x, importmap, application/json next to a form feed, text/plain, a Symbol type and a few more), checks that classic and module scripts are unchanged, and checks the body carried by a streamed patch op.
  • test/runtime/head-json-script.spec.js seeds server-shaped markup and checks that the client claims the same element, that a JSON script the client creates carries the escaped text, and that other data blocks, a Symbol type included, keep their text.

Without the fix, 11 of the 22 server tests and 2 of the 3 client tests fail; the ones that pass either way are the guards for classic scripts and non-JSON data blocks. With it, all pass.

pnpm turbo run build --filter=@solidjs/web   # the template's pnpm build, filtered to this package
cd packages/web
pnpm test        # no failures: jsdom 145 files, server 172 files, hydrate 99 files (1 skipped)
pnpm test-types
npx prettier --check src/head.ts src/server.ts src/client.ts test/server/use-head-json-script.spec.tsx test/runtime/head-json-script.spec.js
cd ../../scripts/size && npm ci && node size.mjs --json /tmp/size-head.json --no-gate && node gate.mjs /tmp/size-head.json /tmp/size-base.json   # base measured the same way on next; every scenario 0 B

Closes #4011

useHead only rewrote `</script` in inline script bodies on the server. In
a JSON-LD script, a value such as `<!--<script>` moves the HTML tokenizer
into the double-escaped state, so the closing `</script>` no longer ends
the element and the markup after it becomes script text, up to a later
`</script>` or the end of the document.

Scripts whose body is JSON (importmap, speculationrules, and the JSON MIME
types: application/json, text/json, any +json subtype) now get every `<`
written as `\u003C`, which parses back to the same value. The shell
markup, the streamed head ops and the client registry share the helper,
so the client writes and compares the same text and hydration adopts the
server element instead of replacing it. Other scripts and data blocks keep
their current body.

On the server the escape is installed by useHead, the only way tags reach
the head registry, so a render without useHead (the server-render size
floor) does not carry it.
@changeset-bot

changeset-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd1f661

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler 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 Oct 13, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 195 untouched benchmarks


Comparing everton-dgn:fix/head-json-script-escaping (bd1f661) with next (6232d18)

Open in CodSpeed

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.

1 participant