Skip to content

SSR spread elements: static text children emitted unescaped; Babel folds textarea value into children with wrong precedence (Oxc divergence) #3557

Description

@ryansolid

Found while adding the spread-static-tail SSR bench (perf/ssr-element-parity). Three related defects on the ssrElement (spread element) path, one of them a Babel/Oxc divergence. Each is a compile-time static, so none is an injection surface, but the output is wrong and the template path already gets all three right.

Reproduction

renderToString on the next runtime, JSX compiled with each compiler (JSX_COMPILER=babel vs native), hydratable: true:

const rest = { id: "a" };
const withValue = { id: "a", value: "from-spread" };

<div {...rest}>a &lt;b&gt; &amp; c</div>
<div>a &lt;b&gt; &amp; c</div>                                 // template path, for comparison
<textarea {...rest} value="a &lt;b&gt; &amp; c" />
<textarea value="a &lt;b&gt; &amp; c" />                       // template path
<textarea {...withValue} value="static" />                     // static after the spread: static must win
<textarea value="static" {...withValue} />                     // static before the spread: spread must win
case native (Oxc) Babel
text child of a spread element <div id="a">a <b> & c</div> <div id="a">a <b> & c</div>
text child, template path <div>a &lt;b&gt; &amp; c</div> same ✅
textarea value, spread element <textarea id="a">a &lt;b> &amp; c</textarea> <textarea id="a">a <b> & c</textarea>
textarea value, template path <textarea>a <b> & c</textarea> same ❌
textarea {...withValue} value="static" static from-spread
textarea value="static" {...withValue} from-spread from-spread

What is happening

1. Static text children of a spread element are never escaped (both compilers). The template path escapes JSX text at compile time into the template string. The spread path collects children for ssrElement instead — Babel createElement's children reduce (t.isJSXTextt.stringLiteral(decode(trimWhitespace(raw)))) and Oxc spread_children_expression (JSXChild::Text) both push the decoded text as a plain string literal with no escapeHTML. ssrElement joins a string child verbatim (it is documented as "escaped above or by the compiler"), so &lt;b&gt; in source becomes a real <b> in the HTML. Parity holds, but both are wrong.

2. Babel folds a textarea value into children on spread elements; Oxc does not (divergence). transformSpecialCaseAttributes (shared/utils.ts) rewrites <textarea value="…"> into a text child for every SSR element, spread or not. Oxc's port of that fold lives in AttrPlanner, which only runs on the template path, so on a spread element Oxc leaves value in the source object and ssrElement's textarea branch (tag === "textarea" && prop === "value"children = escape(value)) renders it. Two consequences of Babel's fold on the spread path:

  • the folded child is unescaped text (defect 1);
  • precedence is wrong: the fold happens before the sources are assembled, so the static becomes children and ssrElement then lets any source's value overwrite children<textarea {...rest} value="static" /> renders the spread's value even though JSX order says the static wins. Oxc's source-object form gets this right for free (later source wins, loser unread).

3. The template-path textarea fold does not escape the folded text (both compilers). <textarea value="a &lt;b&gt;" /> compiles to a template containing <textarea>a <b></textarea>. Textarea is RCDATA so <b> stays literal, but & sequences are decoded by the parser (value="&amp;lt;" displays < instead of &lt;) and a value containing </textarea> ends the element. Milder than 1, same root cause: folded text bypasses the escaping the text-child path applies.

Recommendation

  • Defect 1 — escape in the spread children reduce, in both compilers: escapeHTML(text) (text mode) on JSX text children unless the element is script/style (doNotEscape), exactly as the template path does. Babel ssr/element.ts createElement; Oxc ssr/transform.rs spread_children_expression. ssrElement's contract ("string children arrive escaped") is right and should stay; the compilers are the ones breaking it.
  • Defect 2 — make Babel match Oxc: do not fold textarea value on elements that carry a spread. Guard the fold in transformSpecialCaseAttributes (or skip it from the spread branch) so the value stays an ordinary property of the running source object; the runtime's textarea branch already escapes it and applies source precedence. This is also the cheaper output — no children thunk. Do not resolve the divergence the other way (teaching Oxc to fold): the fold is what produces the precedence bug.
  • Defect 3 — the template-path fold should escape the folded text (text-mode escapeHTML when it creates the JSX text child / template segment), in both compilers together so parity is kept.
  • Add the six cases above as a server spec run under both compilers (the JSX_COMPILER matrix in packages/web), plus SSR fixtures for <div {...s}>text &lt;x&gt;</div> and <textarea {...s} value="…"> in both orders. The cross-mode parity ratchet (compiler/__tests__/parity/expected-cross) will need regenerating for the textarea fixture once Babel stops folding on the spread path.

Related: the SSR spread-element static-tail compile (perf/ssr-element-parity) deliberately excludes textarea value and child properties from the baked attribute string for the reason in defect 2 — they are content, not attributes — so it is unaffected by whichever fix lands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions