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 <b> & c</div>
<div>a <b> & c</div> // template path, for comparison
<textarea {...rest} value="a <b> & c" />
<textarea value="a <b> & 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 <b> & c</div> ✅ |
same ✅ |
| textarea value, spread element |
<textarea id="a">a <b> & 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.isJSXText → t.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 <b> 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 <b>" /> compiles to a template containing <textarea>a <b></textarea>. Textarea is RCDATA so <b> stays literal, but & sequences are decoded by the parser (value="&lt;" displays < instead of <) 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 <x></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.
Found while adding the spread-static-tail SSR bench (
perf/ssr-element-parity). Three related defects on thessrElement(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
renderToStringon thenextruntime, JSX compiled with each compiler (JSX_COMPILER=babelvs native),hydratable: true:<div id="a">a <b> & c</div>❌<div id="a">a <b> & c</div>❌<div>a <b> & c</div>✅<textarea id="a">a <b> & c</textarea>✅<textarea id="a">a <b> & c</textarea>❌<textarea>a <b> & c</textarea>❌{...withValue} value="static"static✅from-spread❌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
ssrElementinstead — BabelcreateElement's children reduce (t.isJSXText→t.stringLiteral(decode(trimWhitespace(raw)))) and Oxcspread_children_expression(JSXChild::Text) both push the decoded text as a plain string literal with noescapeHTML.ssrElementjoins a string child verbatim (it is documented as "escaped above or by the compiler"), so<b>in source becomes a real<b>in the HTML. Parity holds, but both are wrong.2. Babel folds a textarea
valueinto 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 inAttrPlanner, which only runs on the template path, so on a spread element Oxc leavesvaluein the source object andssrElement's textarea branch (tag === "textarea" && prop === "value"→children = escape(value)) renders it. Two consequences of Babel's fold on the spread path:childrenandssrElementthen lets any source'svalueoverwritechildren—<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 <b>" />compiles to a template containing<textarea>a <b></textarea>. Textarea is RCDATA so<b>stays literal, but&sequences are decoded by the parser (value="&lt;"displays<instead of<) 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
escapeHTML(text)(text mode) on JSX text children unless the element isscript/style(doNotEscape), exactly as the template path does. Babelssr/element.tscreateElement; Oxcssr/transform.rsspread_children_expression.ssrElement's contract ("string children arrive escaped") is right and should stay; the compilers are the ones breaking it.valueon elements that carry a spread. Guard the fold intransformSpecialCaseAttributes(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.escapeHTMLwhen it creates the JSX text child / template segment), in both compilers together so parity is kept.JSX_COMPILERmatrix inpackages/web), plus SSR fixtures for<div {...s}>text <x></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 textareavalueand 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.