diff --git a/apps/oxfmt/test/api/sort_tailwindcss.test.ts b/apps/oxfmt/test/api/sort_tailwindcss.test.ts index 8c3cbb550f..af7a9eab9e 100644 --- a/apps/oxfmt/test/api/sort_tailwindcss.test.ts +++ b/apps/oxfmt/test/api/sort_tailwindcss.test.ts @@ -566,6 +566,100 @@ shadow-lg\` : "font-normal"}\`} />;`; expect(result.errors).toStrictEqual([]); }); + it("should not break JSX element for multiline class string that gets collapsed", async () => { + const input = `const A = ( +
+ Hello +
+); +const B = ( +
+ Hello +
+); +`; + + const result = await format("test.tsx", input, { + experimentalTailwindcss: {}, + }); + + // The value is collapsed into one line, so the element should not be expanded + // as if the value were still multiline. + expect(result.code).toMatchInlineSnapshot(` + "const A =
Hello
; + const B = ( +
+ Hello +
+ ); + " + `); + expect(result.errors).toStrictEqual([]); + + // Formatting the output again should not change it. + const second = await format("test.tsx", result.code, { + experimentalTailwindcss: {}, + }); + expect(second.code).toBe(result.code); + }); + + it("should still break JSX element for multiline class string when preserveWhitespace is true", async () => { + const input = `const A = ( +
+ Hello +
+); +`; + + const result = await format("test.tsx", input, { + experimentalTailwindcss: { preserveWhitespace: true }, + }); + + expect(result.code).toBe(input); + expect(result.errors).toStrictEqual([]); + }); + + it("should still break JSX element for multiline non-Tailwind string attribute", async () => { + const input = `const A = ( +
+ Hello +
+); +`; + + const result = await format("test.tsx", input, { + experimentalTailwindcss: {}, + }); + + expect(result.code).toMatchInlineSnapshot(` + "const A = ( +
+ Hello +
+ ); + " + `); + expect(result.errors).toStrictEqual([]); + }); + // Tests for can_collapse_whitespace in template literal expressions // These test the whitespace preservation logic based on adjacent quasi whitespace diff --git a/crates/oxc_formatter/src/print/jsx/mod.rs b/crates/oxc_formatter/src/print/jsx/mod.rs index 2bcbe80fb8..3a1c00148d 100644 --- a/crates/oxc_formatter/src/print/jsx/mod.rs +++ b/crates/oxc_formatter/src/print/jsx/mod.rs @@ -15,13 +15,12 @@ use crate::{ ast_nodes::{AstNode, AstNodes}, format_args, formatter::{ - TailwindContextEntry, prelude::*, trivia::{DanglingIndentMode, FormatDanglingComments}, }, utils::{ expression::as_call_expression_without_chain_wrappers, - tailwindcss::is_tailwind_jsx_attribute, + tailwindcss::tailwind_context_for_jsx_attribute, }, write, }; @@ -300,14 +299,7 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, JSXAttribute<'a>> { write!(f, self.name()); if let Some(value) = &self.value() { - // Check if this is a Tailwind attribute and push context - // Extract context entry before mutating f - let tailwind_ctx_to_push = f - .options() - .sort_tailwindcss - .as_ref() - .filter(|opts| is_tailwind_jsx_attribute(&self.name, opts)) - .map(|opts| TailwindContextEntry::new(opts.preserve_whitespace)); + let tailwind_ctx_to_push = tailwind_context_for_jsx_attribute(&self.name, f); if let Some(ctx) = tailwind_ctx_to_push { f.context_mut().push_tailwind_context(ctx); diff --git a/crates/oxc_formatter/src/print/jsx/opening_element.rs b/crates/oxc_formatter/src/print/jsx/opening_element.rs index 01fa114fb4..56033bd922 100644 --- a/crates/oxc_formatter/src/print/jsx/opening_element.rs +++ b/crates/oxc_formatter/src/print/jsx/opening_element.rs @@ -1,9 +1,12 @@ use std::ops::Deref; -use oxc_ast::ast::{JSXAttributeItem, JSXAttributeValue, JSXOpeningElement, StringLiteral}; +use oxc_ast::ast::{JSXAttributeItem, JSXAttributeValue, JSXOpeningElement}; use oxc_span::GetSpan; -use crate::{ast_nodes::AstNode, formatter::prelude::*, write}; +use crate::{ + ast_nodes::AstNode, formatter::prelude::*, + utils::tailwindcss::tailwind_context_for_jsx_attribute, write, +}; pub struct FormatOpeningElement<'a, 'b> { element: &'b AstNode<'b, 'a, JSXOpeningElement<'a>>, @@ -45,7 +48,7 @@ impl<'a, 'b> FormatOpeningElement<'a, 'b> { } else if attributes.len() == 1 && !name_has_comment && !last_attribute_has_comment - && is_single_line_string_literal_attribute(&attributes[0]) + && string_literal_attribute_has_line_break(&attributes[0], f) == Some(false) { OpeningElementLayout::SingleStringAttribute } else { @@ -54,17 +57,28 @@ impl<'a, 'b> FormatOpeningElement<'a, 'b> { } } -/// Returns `true` if this is an attribute with a [`StringLiteral`] initializer that contains at least one new line character. -fn is_multiline_string_literal_attribute(attribute: &JSXAttributeItem<'_>) -> bool { +/// Whether this attribute's string literal value is printed with a line break, +/// or `None` if the value isn't a string literal. +/// - Tailwind sorting without `preserveWhitespace` collapses line breaks, +/// so checking the source value alone would make formatting non-idempotent +fn string_literal_attribute_has_line_break( + attribute: &JSXAttributeItem<'_>, + f: &JsFormatter<'_, '_>, +) -> Option { let JSXAttributeItem::Attribute(attr) = attribute else { - return false; + return None; }; - attr.value.as_ref().is_some_and(|value| { - matches!( - value, - JSXAttributeValue::StringLiteral(s) if s.value.contains(|c: char| matches!(c, '\n' | '\r')) - ) - }) + let Some(JSXAttributeValue::StringLiteral(string)) = &attr.value else { + return None; + }; + + if !string.value.contains(|c: char| matches!(c, '\n' | '\r')) { + return Some(false); + } + + let collapses_whitespace = tailwind_context_for_jsx_attribute(&attr.name, f) + .is_some_and(|ctx| !ctx.preserve_whitespace); + Some(!collapses_whitespace) } impl<'a> Format<'a, JsFormatContext<'a>> for FormatOpeningElement<'a, '_> { @@ -115,9 +129,9 @@ impl<'a> Format<'a, JsFormatContext<'a>> for FormatOpeningElement<'a, '_> { } }); - let has_multiline_string_attribute = attributes - .iter() - .any(|attribute| is_multiline_string_literal_attribute(&attribute)); + let has_multiline_string_attribute = attributes.iter().any(|attribute| { + string_literal_attribute_has_line_break(&attribute, f) == Some(true) + }); write!(f, [group(&format_inner).should_expand(has_multiline_string_attribute)]); } } @@ -153,26 +167,3 @@ pub enum OpeningElementLayout { /// ``` IndentAttributes { name_has_comment: bool, last_attribute_has_comment: bool }, } - -/// Returns `true` if this is an attribute with a string literal initializer that does not contain any new line characters. -fn is_single_line_string_literal_attribute(attribute: &JSXAttributeItem) -> bool { - as_string_literal_attribute_value(attribute) - .is_some_and(|string| !string.value.contains(|c: char| matches!(c, '\n' | '\r'))) -} - -/// Returns `Some` if the initializer value of this attribute is a string literal. -/// Returns [None] otherwise. -fn as_string_literal_attribute_value<'a>( - attribute: &'a JSXAttributeItem<'a>, -) -> Option<&'a StringLiteral<'a>> { - match attribute { - JSXAttributeItem::Attribute(attr) => { - if let Some(JSXAttributeValue::StringLiteral(string)) = &attr.value { - Some(string.as_ref()) - } else { - None - } - } - JSXAttributeItem::SpreadAttribute(_) => None, - } -} diff --git a/crates/oxc_formatter/src/utils/tailwindcss.rs b/crates/oxc_formatter/src/utils/tailwindcss.rs index 75c371c661..7756a4ef1d 100644 --- a/crates/oxc_formatter/src/utils/tailwindcss.rs +++ b/crates/oxc_formatter/src/utils/tailwindcss.rs @@ -50,7 +50,7 @@ pub fn tailwind_context_for_string_literal<'a>( /// Returns `true` for: /// - `class` and `className` (default attributes) /// - Custom attributes specified in `attributes` option -pub fn is_tailwind_jsx_attribute( +fn is_tailwind_jsx_attribute( attr_name: &JSXAttributeName<'_>, options: &SortTailwindcssOptions, ) -> bool { @@ -68,6 +68,20 @@ pub fn is_tailwind_jsx_attribute( options.attributes.iter().any(|a| a == name) } +/// Returns the Tailwind context to push while printing the value of the JSX attribute `attr_name`. +/// +/// Also read by the opening element layout, which must agree with the print on whether the value is collapsed. +pub fn tailwind_context_for_jsx_attribute( + attr_name: &JSXAttributeName<'_>, + f: &JsFormatter<'_, '_>, +) -> Option { + f.options() + .sort_tailwindcss + .as_ref() + .filter(|opts| is_tailwind_jsx_attribute(attr_name, opts)) + .map(|opts| TailwindContextEntry::new(opts.preserve_whitespace)) +} + /// Checks if a callee expression is a Tailwind function. /// /// Traverses through `CallExpression` and `MemberExpression` nodes to find the