diff --git a/changelog_unreleased/javascript/19161.md b/changelog_unreleased/javascript/19161.md
new file mode 100644
index 000000000..ce2969c38
--- /dev/null
+++ b/changelog_unreleased/javascript/19161.md
@@ -0,0 +1,37 @@
+#### Preserve blank lines between JSX attributes (#19161 by @Poliklot)
+
+Prettier now keeps one existing blank line between JSX attributes, consistent with how it preserves blank lines in many other places. Multiple blank lines are still collapsed to one.
+
+
+```jsx
+// Input
+;
+
+// Prettier stable
+;
+
+// Prettier main
+;
+```
diff --git a/src/language-js/print/jsx.js b/src/language-js/print/jsx.js
index e8f50335d..9330dba0a 100644
--- a/src/language-js/print/jsx.js
+++ b/src/language-js/print/jsx.js
@@ -25,6 +25,7 @@ import { CommentCheckFlags, hasComment } from "../utilities/comments.js";
import { createTypeCheckFunction } from "../utilities/create-type-check-function.js";
import { getRaw } from "../utilities/get-raw.js";
import { isMeaningfulJsxText } from "../utilities/is-meaningful-jsx-text.js";
+import { isNextLineEmpty } from "../utilities/is-next-line-empty.js";
import { jsxWhitespace } from "../utilities/jsx-whitespace.js";
import {
isArrayExpression,
@@ -652,7 +653,19 @@ function printJsxOpeningElement(path, options, print) {
"<",
print("name"),
print("typeArguments"),
- indent(path.map(() => [attributeLine, print()], "attributes")),
+ indent(
+ path.map(
+ ({ isFirst, previous }) => [
+ isFirst
+ ? attributeLine
+ : isNextLineEmpty(previous, options)
+ ? [hardline, hardline]
+ : attributeLine,
+ print(),
+ ],
+ "attributes",
+ ),
+ ),
...printEndOfOpeningTag(node, options, nameHasComments),
],
{ shouldBreak },
diff --git a/tests/format/jsx/attribute-blank-lines/__snapshots__/format.test.js.snap b/tests/format/jsx/attribute-blank-lines/__snapshots__/format.test.js.snap
new file mode 100644
index 000000000..4b3630b6f
--- /dev/null
+++ b/tests/format/jsx/attribute-blank-lines/__snapshots__/format.test.js.snap
@@ -0,0 +1,171 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`attribute-blank-lines.js - {"singleAttributePerLine":true} format 1`] = `
+====================================options=====================================
+parsers: ["flow", "babel", "typescript"]
+singleAttributePerLine: true
+ printWidth: 80 (default) |
+=====================================input======================================
+const basic = (
+
+);
+
+const multipleBlankLines = (
+
+);
+
+const spreadAttributes = (
+
+);
+
+const noBlankLines = (
+
+);
+
+const inlineAttributes = ;
+
+=====================================output=====================================
+const basic = (
+
+);
+
+const multipleBlankLines = (
+
+);
+
+const spreadAttributes = (
+
+);
+
+const noBlankLines = (
+
+);
+
+const inlineAttributes = (
+
+);
+
+================================================================================
+`;
+
+exports[`attribute-blank-lines.js format 1`] = `
+====================================options=====================================
+parsers: ["flow", "babel", "typescript"]
+ printWidth: 80 (default) |
+=====================================input======================================
+const basic = (
+
+);
+
+const multipleBlankLines = (
+
+);
+
+const spreadAttributes = (
+
+);
+
+const noBlankLines = (
+
+);
+
+const inlineAttributes = ;
+
+=====================================output=====================================
+const basic = (
+
+);
+
+const multipleBlankLines = (
+
+);
+
+const spreadAttributes = (
+
+);
+
+const noBlankLines = ;
+
+const inlineAttributes = ;
+
+================================================================================
+`;
diff --git a/tests/format/jsx/attribute-blank-lines/attribute-blank-lines.js b/tests/format/jsx/attribute-blank-lines/attribute-blank-lines.js
new file mode 100644
index 000000000..2857bdd71
--- /dev/null
+++ b/tests/format/jsx/attribute-blank-lines/attribute-blank-lines.js
@@ -0,0 +1,38 @@
+const basic = (
+
+);
+
+const multipleBlankLines = (
+
+);
+
+const spreadAttributes = (
+
+);
+
+const noBlankLines = (
+
+);
+
+const inlineAttributes = ;
diff --git a/tests/format/jsx/attribute-blank-lines/format.test.js b/tests/format/jsx/attribute-blank-lines/format.test.js
new file mode 100644
index 000000000..fcc17a3a2
--- /dev/null
+++ b/tests/format/jsx/attribute-blank-lines/format.test.js
@@ -0,0 +1,4 @@
+runFormatTest(import.meta, ["flow", "babel", "typescript"]);
+runFormatTest(import.meta, ["flow", "babel", "typescript"], {
+ singleAttributePerLine: true,
+});
diff --git a/tests/format/jsx/jsx/__snapshots__/format.test.js.snap b/tests/format/jsx/jsx/__snapshots__/format.test.js.snap
index a77dad69c..8c3f41a46 100644
--- a/tests/format/jsx/jsx/__snapshots__/format.test.js.snap
+++ b/tests/format/jsx/jsx/__snapshots__/format.test.js.snap
@@ -4988,21 +4988,29 @@ singleQuote: false
+ first={1}
+
+ second={2}
+ />
+);
+
+=====================================output=====================================
+const element = (
+
+ first={1}
+
+ second={2}
+ />
+);
+
+================================================================================
+`;
+
exports[`generic-component.tsx format 1`] = `
====================================options=====================================
parsers: ["typescript"]
diff --git a/tests/format/typescript/tsx/attribute-blank-lines.tsx b/tests/format/typescript/tsx/attribute-blank-lines.tsx
new file mode 100644
index 000000000..0cb1a1dc4
--- /dev/null
+++ b/tests/format/typescript/tsx/attribute-blank-lines.tsx
@@ -0,0 +1,7 @@
+const element = (
+
+ first={1}
+
+ second={2}
+ />
+);