Skip to content

fix(react): escape newlines in JSX quoted attribute values#11728

Open
teee32 wants to merge 4 commits intoswc-project:mainfrom
teee32:fix/jsx-attr-newline-escape
Open

fix(react): escape newlines in JSX quoted attribute values#11728
teee32 wants to merge 4 commits intoswc-project:mainfrom
teee32:fix/jsx-attr-newline-escape

Conversation

@teee32
Copy link
Copy Markdown

@teee32 teee32 commented Mar 23, 2026

Problem

Literal newlines inside quoted JSX attribute string values are incorrectly
collapsed to a single space in the JSX transform. For example:

```jsx
// Input

hello

// Current (wrong) output
React.createElement("div", { "data-anything": "line1 line2" }, "hello");

// Expected output
React.createElement("div", { "data-anything": "line1\nline2" }, "hello");
```

This causes real-world issues such as hydration mismatches in Next.js
applications (see #11550)
where data attributes containing newlines are corrupted.

Root Cause

In transform_jsx_attr_str() at
crates/swc_ecma_transforms_react/src/jsx/mod.rs, the '\n' | '\r' | '\t'
match arm collapses all three characters to a single space, consuming any
trailing spaces:

```rust
'\n' | '\r' | '\t' => {
buf.push_char(' ');
while let Some(next) = iter.peek() {
if next.to_char() == Some(' ') {
iter.next();
} else {
break;
}
}
}
```

Literal \n and \r in JavaScript strings must be represented as escape
sequences (\n, \r), not collapsed to whitespace. Previous PR attempts
(#11556,
#11569) tried pushing
literal characters but the outputs were interpreted as invalid JS (literal
newlines in string literals are a syntax error). The codegen at
crates/swc_ecma_codegen/src/lit.rs already handles escaping LINE_FEED
and CARRIAGE_RETURN — the transform should push actual newline characters
and let codegen produce the correct escape sequences.

Change

Split the combined '\n' | '\r' | '\t' match arm into three separate arms:

```rust
'\n' => buf.push_char('\n'), // Push actual char; codegen escapes to \\n
'\r' => buf.push_char('\r'), // Push actual char; codegen escapes to \\r
'\t' => {
buf.push_char(' '); // kept as-is
while let Some(next) = iter.peek() {
if next.to_char() == Some(' ') {
iter.next();
} else {
break;
}
}
}
```

  • '\n': now pushes the actual newline codepoint; the existing codegen at
    lit.rs:489 handles escaping it to \\n (single-backslash escape)
  • '\r': same — codegen at lit.rs:490 escapes to \\r
  • '\t': behaviour unchanged — tabs are horizontal whitespace and are
    intentionally collapsed to a single space with trailing-space consumption

Why This Fix

  1. Correct escaping: pushing actual newline chars means the codegen's
    existing escape logic (designed precisely for this purpose) handles the
    output correctly
  2. Produces valid JS: the codegen emits \n escape sequences, which
    are syntactically valid in JS string literals
  3. Minimal: only 2 lines change in one function
  4. Backward compatible for the important case: tab collapsing is preserved

Risk

  • Low: the change is confined to transform_jsx_attr_str() which is only
    called during JSX attribute value transformation
  • Existing tab-collapsing behaviour is preserved
  • All affected test fixtures (3 existing + 1 new regression test) have been
    updated and verified

Validation

  • cargo test -p swc_ecma_transforms_react jsx — 227 tests pass
  • cargo test -p swc --test projects — 869 tests pass
  • cargo clippy -p swc_ecma_transforms_react — no warnings
  • cargo fmt --all — formatted

Test Coverage

  • New fixture: tests/jsx/fixture/issue-11550/ — regression test for the
    reported case
  • Updated fixtures: codegen/jsx-1, issues-1233/case-1,
    issues-2162/case4, vercel/1

Closes #11550

Copilot AI review requested due to automatic review settings March 23, 2026 12:24
@teee32 teee32 requested a review from a team as a code owner March 23, 2026 12:24
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 23, 2026

⚠️ No Changeset found

Latest commit: bfe1cc4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ca1a5c811

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2195 to +2196
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve newline characters instead of inserting \\n text

Str.value holds the semantic string contents, not already-escaped source text. Replacing \n/\r with the two-character sequences "\\n" and "\\r" here means the emitter will escape those backslashes again in crates/swc_ecma_codegen/src/lit.rs:489-490, so <div data-anything="a b" /> now lowers to a JS literal like "a\\nb" whose runtime value is backslash-n, not an actual newline. This affects every quoted JSX attribute containing a line break, so the change still produces incorrect props for the regression it is trying to fix.

Useful? React with 👍 / 👎.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 23, 2026

Merging this PR will not alter performance

✅ 219 untouched benchmarks


Comparing teee32:fix/jsx-attr-newline-escape (bfe1cc4) with main (236eff0)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (87dee57) during the generation of this report, so 236eff0 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes newline handling for quoted JSX attribute string values in the React JSX transform to avoid collapsing line breaks into spaces (issue #11550), aiming to preserve correct runtime string semantics and prevent hydration mismatches.

Changes:

  • Updates transform_jsx_attr_str in the React JSX transform to handle \n / \r differently from \t.
  • Adds a new regression fixture for issue #11550.
  • Updates multiple fixture expected outputs to reflect the new newline behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
crates/swc_ecma_transforms_react/src/jsx/mod.rs Changes JSX attribute string transformation for \n/\r/\t.
crates/swc_ecma_transforms_react/tests/jsx/fixture/issue-11550/input.js Adds regression input covering newline in quoted JSX attr.
crates/swc_ecma_transforms_react/tests/jsx/fixture/issue-11550/output.mjs Adds regression expected output (currently reflects double-escaped newline).
crates/swc_ecma_transforms_react/tests/jsx/fixture/vercel/1/output.mjs Updates expected output for multiline quoted attr values.
crates/swc/tests/fixture/codegen/jsx-1/output/index.js Updates expected output for multiline quoted attr values.
crates/swc/tests/fixture/issues-1xxx/1233/case-1/output/index.js Updates expected output for newline in quoted attr value.
crates/swc/tests/fixture/issues-2xxx/2162/case4/output/index.js Updates expected output for backslash + newline sequence in quoted attr value.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2195 to +2196
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transform_jsx_attr_str is inserting the two-character sequences \\n / \\r into Str.value. The code generator already escapes actual line terminators in string values (see get_quoted_utf16 handling of LINE_FEED/CARRIAGE_RETURN), so this change causes double-escaping and changes runtime semantics (output becomes "\\n" which is a backslash+n, not a newline). Instead, keep \n/\r as actual characters in the string value (e.g., push the newline/carriage-return codepoints) and let codegen emit the escape sequences.

Suggested change
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
'\n' => buf.push_char('\n'),
'\r' => buf.push_char('\r'),

Copilot uses AI. Check for mistakes.
// Newline in quoted JSX attribute value should be escaped, not collapsed to space
// https://github.com/swc-project/swc/issues/11550
const hello = /*#__PURE__*/ React.createElement("div", {
"data-anything": "bruh\\nbruh"
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture output currently contains \\n inside a JS string literal (e.g., "bruh\\nbruh"), which represents a literal backslash+n at runtime. If the intent is to preserve an actual newline from the JSX attribute, the emitted JS source should contain \n (single backslash) escape, not \\n. Update expected output after fixing the transform to avoid double-escaping.

Suggested change
"data-anything": "bruh\\nbruh"
"data-anything": "bruh\nbruh"

Copilot uses AI. Check for mistakes.
className: b,
header: "C",
subheader: "D E"
subheader: "D\\n E"
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected output string literal uses \\n (double backslash), which encodes a literal \n sequence at runtime. For preserving a newline from the JSX attribute, the JS output should contain \n (single backslash) so that runtime value contains an actual line feed. Update this fixture expectation after adjusting the transform to avoid double-escaping.

Suggested change
subheader: "D\\n E"
subheader: "D\n E"

Copilot uses AI. Check for mistakes.
function Component() {
return /*#__PURE__*/ React.createElement("div", {
name: "A B"
name: "A\\n B"
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected output string literal uses \\n (double backslash), which yields a literal backslash+n in the runtime string. If the JSX attribute contains a real newline, the emitted JS should use the \n escape (single backslash) so the runtime value contains a line feed. Update this fixture after fixing the transform to prevent double-escaping.

Suggested change
name: "A\\n B"
name: "A\n B"

Copilot uses AI. Check for mistakes.
function test() {
return /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(A, {
b: "\\ "
b: "\\\\n "
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expectation currently emits b: "\\\\n " (four backslashes then n), which results in a runtime string starting with \\n... (literal backslashes + n), not a backslash followed by a newline. After fixing newline handling in the JSX attr transform, this fixture should expect an actual newline escape (\n) in the emitted JS rather than a doubly-escaped sequence.

Suggested change
b: "\\\\n "
b: "\n "

Copilot uses AI. Check for mistakes.
className: b,
header: "C",
subheader: "D E"
subheader: "D\\n E"
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected output uses \\n (double backslash) in the string literal for subheader, which encodes a literal backslash+n at runtime. To preserve the newline from the JSX attribute value, the JS output should contain a single \n escape so runtime string contains an actual line feed. Update this fixture expectation after fixing the transform to avoid double-escaping.

Suggested change
subheader: "D\\n E"
subheader: "D\n E"

Copilot uses AI. Check for mistakes.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 23, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ kdy1
❌ GG ZIBLAKING


GG ZIBLAKING seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 23, 2026

Binary Sizes

File Size
swc.linux-x64-gnu.node 28M (29075144 bytes)

Commit: a05cb73

Newlines in quoted JSX attribute string values were incorrectly collapsed
to a single space in `transform_jsx_attr_str()`. Literal newlines in JS
strings must be escaped as `\n`, not collapsed to whitespace.

Fix: split the combined `'\n' | '\r' | '\t'` match arm into separate arms,
escaping `\n` as `\\n` and `\r` as `\\r` while preserving the original
`\t`-collapsing behaviour (tabs are horizontal whitespace and are
intentionally collapsed to a space).

Closes swc-project#11550.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@teee32 teee32 force-pushed the fix/jsx-attr-newline-escape branch from 1ca1a5c to ff7676a Compare March 23, 2026 12:35
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff7676ab91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2197 to 2201
'\t' => {
buf.push_char(' ');

while let Some(next) = iter.peek() {
if next.to_char() == Some(' ') {
iter.next();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve tabs when keeping multiline JSX attr values

When a quoted JSX attribute spans lines in a tab-indented file, this branch still rewrites \t to a plain space and drops any following spaces. After the new \n/\r handling, an input like <div attr="a\n\tb" /> now lowers to a runtime value of "a\n b" instead of preserving the original tab, so multiline attribute strings are still corrupted for projects that indent JSX with tabs.

Useful? React with 👍 / 👎.

'\t' => {
buf.push_char(' ');

while let Some(next) = iter.peek() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiline JSX attribute values still normalize \t to a space in this branch.

Because this arm does buf.push_char(' ') (and then consumes following spaces), an input like <A x="a\n\tb" /> is lowered to a runtime value of "a\n b" instead of preserving the tab ("a\n\tb", matching TypeScript/Babel behavior).

So the newline fix is correct, but tab-indented multiline attrs are still semantically changed. Could we preserve \t here (for example buf.push_char('\t')) and add a fixture for that case?

' ' => buf.push_char(' '),

'\n' | '\r' | '\t' => {
'\n' => buf.push_char('\n'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix for LF, but this exposes a CRLF regression. In read_jsx_str (crates/swc_ecma_parser/src/lexer/mod.rs), when read_jsx_new_line(false) consumes \r\n, chunk_start is still set via cur_pos + BytePos(ch.len_utf8() as _) (line 1505), so the \n gets re-sliced and appended again. I reproduced this by changing the fixture input newline to CRLF: the emitted value becomes "bruh\\r\\n\\nbruh" (extra LF). Could we advance chunk_start to the current lexer position after consuming the newline sequence and add a CRLF fixture?

' ' => buf.push_char(' '),

'\n' | '\r' | '\t' => {
'\n' => buf.push_char('\n'),
Copy link
Copy Markdown
Member

@kdy1 kdy1 Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change makes multiline JSX attribute strings diverge from Babel semantics. Babel applies value.value.replace(/\n\s+/g, " ") in @babel/plugin-transform-react-jsx, so inputs like <div a="x\n y" /> compile to "x y" (newline + indentation collapsed), while <div a="x\ny" /> keeps the newline. With unconditional '\n' => buf.push_char('\n') here, SWC now emits "x\n y", changing runtime values for existing indented multiline attributes and regressing fixtures such as codegen/jsx-1, issues-1233, and issues-2162. Could we preserve \n only when it is not followed by horizontal whitespace, and keep collapsing \n[ \t]+ to a single space?

Copilot AI review requested due to automatic review settings April 7, 2026 06:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

JSX parser collapses newline to space in quoted attribute values

4 participants