Skip to content

Emit style and script contents as raw text - #389

Merged
brainkim merged 1 commit into
mainfrom
fix/html-raw-text
Sep 14, 2026
Merged

brainkim merged 1 commit into
mainfrom
fix/html-raw-text

Conversation

@brainkim

@brainkim brainkim commented Sep 14, 2026

Copy link
Copy Markdown
Member

Browsers never decode character references inside <style> and <script>, but the HTML renderer escaped their text like any other, so served CSS and code were broken. Hydration patched the text on pages with client JavaScript, hiding the bug; static pages stayed broken. Found via bikeshaving.org's stylesheet.

renderer.render(<style>{`a > b { font-family: "Atkinson"; }`}</style>);
// before: <style>a &gt; b { font-family: &quot;Atkinson&quot;; }</style>
// after:  <style>a > b { font-family: "Atkinson"; }</style>

A closing tag sequence is neutralized, case-preservingly, with an identity escape valid in CSS, JavaScript strings, and JSON:

renderer.render(<script>{'const s = "</script>";'}</script>);
// <script>const s = "<\/script>";</script>

So JSON-LD and import maps round-trip:

renderer.render(
  <script type="application/ld+json">
    {JSON.stringify({url: "https://example.com/</script>"})}
  </script>,
);
// JSON.parse of the contents returns the original object

Escaping stays where the parser does decode entities — style/script inside inline SVG (foreign content) and <title>/<textarea> (escapable raw text):

renderer.render(<svg><style>{"a > b {}"}</style></svg>);
// <svg><style>a &gt; b {}</style></svg>

Raw and innerHTML are unchanged. Mechanically, text() keeps the raw string alongside the escaped one and arrange(), which knows the tag and scope, chooses.

Nine new tests (raw output, neutralizations, the carve-outs, a JSON-LD round-trip, a zero-warning hydration case). One docs paragraph: Raw is no longer needed for style/script, except a dynamic script body containing <!-- together with <script. CHANGELOG entry; skill references regenerated.

🤖 Generated with Claude Code

https://claude.ai/code/session_019pggktip8wsuxzy2VCY9p7

@brainkim brainkim changed the title Render style and script contents as raw text in the HTML renderer Emit style and script contents as raw text Sep 14, 2026
@brainkim
brainkim force-pushed the fix/html-raw-text branch 4 times, most recently from 40ed959 to 6d18769 Compare September 14, 2026 14:52
Text children of style and script were entity-escaped like any other
text, but browsers never decode character references inside raw text
elements, so the CSS and code shipped broken — and hydration silently
patched the text on pages with client JavaScript, hiding the bug. Their
contents now render verbatim, with a closing tag sequence neutralized as
an identity escape valid in CSS, JavaScript strings, and JSON. Escaping
stays for style and script inside inline SVG, where the parser does
decode entities, and for title and textarea as escapable raw text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pggktip8wsuxzy2VCY9p7
@brainkim
brainkim merged commit 3b4859b into main Sep 14, 2026
9 checks passed
@brainkim
brainkim deleted the fix/html-raw-text branch September 14, 2026 17:22
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