diff --git a/apps/oxfmt/test/api/sort_tailwindcss.test.ts b/apps/oxfmt/test/api/sort_tailwindcss.test.ts
index 28043fadd7..515a1da1b4 100644
--- a/apps/oxfmt/test/api/sort_tailwindcss.test.ts
+++ b/apps/oxfmt/test/api/sort_tailwindcss.test.ts
@@ -697,7 +697,8 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
const result = await format("test.ts", input, options);
// The line breaks are collapsed, so the template is not hugged as multiline,
- // and the collapsed key and call arguments are short.
+ // and the collapsed key and Tailwind call arguments are short.
+ // `foo()` is not a Tailwind function, so its argument is kept and measured as is.
expect(result.code).toMatchInlineSnapshot(`
"const a = clsx(
\`flex items-center justify-between p-4 \${active ? "bg-white" : "bg-black"} text-black hover:bg-gray-100\`,
@@ -714,8 +715,9 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
const eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee =
clsx(\`flex p-4\`);
const f = clsx({
- kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk:
- foo(\`flex p-4\`),
+ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk: foo(
+ \`p-4 flex\`,
+ ),
});
"
`);
@@ -1005,6 +1007,37 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
expect(result.errors).toStrictEqual([]);
});
+ it("should NOT sort template literals inside nested non-Tailwind call expressions", async () => {
+ const input = `const a = cn(x.includes(\`a b\`) && "p-4 flex");
+const b = cn(x.includes(\`\${"a b"} c\`));
+const C =
;
+const D =
;
+`;
+ const options = { experimentalTailwindcss: { functions: ["cn"] } };
+
+ const result = await format("test.tsx", input, options);
+
+ // Like strings, the templates (and strings inside them) are kept as is
+ expect(result.code).toMatchInlineSnapshot(`
+ "const a = cn(x.includes(\`a b\`) && "flex p-4");
+ const b = cn(x.includes(\`\${"a b"} c\`));
+ const C =
;
+ const D = (
+
+ );
+ "
+ `);
+ expect(result.errors).toStrictEqual([]);
+
+ // Formatting the output again should not change it.
+ const second = await format("test.tsx", result.code, options);
+ expect(second.code).toBe(result.code);
+ });
+
it("should preserve escape sequences in nested call expressions", async () => {
// Various escape sequences should be preserved in nested calls
const input = `const A = {
/// structures (e.g., `{ a: { "b-c": 1 } }` where only the inner object needs quoted keys).
quote_needed_stack: Vec,
- /// Stack tracking whether we're inside a Tailwind class context.
- /// When non-empty, StringLiterals should be sorted as Tailwind classes.
- tailwind_context_stack: Vec,
+ /// Stack tracking whether we're inside a Tailwind class context, read by [`Self::tailwind_context`].
+ /// - `None` hides the context below it, inside a nested non-Tailwind call
+ tailwind_context_stack: Vec>,
/// Set only for an embedded program (see [`crate::format_to_ir`]):
/// its sole statement prints without its semicolon.
@@ -268,7 +261,13 @@ impl<'ast> JsFormatContext<'ast> {
/// Push a Tailwind context entry onto the stack.
/// Call this when entering a JSXAttribute or CallExpression with Tailwind class context.
pub fn push_tailwind_context(&mut self, entry: TailwindContextEntry) {
- self.tailwind_context_stack.push(entry);
+ self.tailwind_context_stack.push(Some(entry));
+ }
+
+ /// Hide the current Tailwind context until the matching [`Self::pop_tailwind_context`].
+ /// Call this when entering a nested non-Tailwind CallExpression.
+ pub fn hide_tailwind_context(&mut self) {
+ self.tailwind_context_stack.push(None);
}
/// Pop a Tailwind context entry from the stack.
@@ -278,13 +277,9 @@ impl<'ast> JsFormatContext<'ast> {
}
/// Get the current Tailwind context, if any.
- /// Returns `Some` if we're inside a Tailwind class context (JSXAttribute or CallExpression).
+ /// Returns `Some` if we're inside a Tailwind class context (JSXAttribute or CallExpression),
+ /// and not inside a nested non-Tailwind call (see [`Self::hide_tailwind_context`]).
pub fn tailwind_context(&self) -> Option<&TailwindContextEntry> {
- self.tailwind_context_stack.last()
- }
-
- /// Get a mutable reference to the current Tailwind context, if any.
- pub fn tailwind_context_mut(&mut self) -> Option<&mut TailwindContextEntry> {
- self.tailwind_context_stack.last_mut()
+ self.tailwind_context_stack.last().and_then(Option::as_ref)
}
}
diff --git a/crates/oxc_formatter/src/print/call_like_expression/mod.rs b/crates/oxc_formatter/src/print/call_like_expression/mod.rs
index defe59616d..2732512679 100644
--- a/crates/oxc_formatter/src/print/call_like_expression/mod.rs
+++ b/crates/oxc_formatter/src/print/call_like_expression/mod.rs
@@ -26,18 +26,14 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, CallExpression<'a>> {
// Check if this is a Tailwind function call (e.g., clsx, cn, tw)
let tailwind_ctx_to_push = tailwind_context_for_callee(&self.callee, f);
- // For nested non-Tailwind calls inside a Tailwind context, disable sorting
- // to prevent sorting strings inside the nested call's arguments.
+ // For nested non-Tailwind calls inside a Tailwind context, hide the context
+ // to prevent sorting strings and templates inside the nested call's arguments.
// (e.g., `classNames("a", x.includes("\n") ? "b" : "c")` - don't sort "\n")
- let was_disabled = if tailwind_ctx_to_push.is_none()
- && let Some(ctx) = f.context_mut().tailwind_context_mut()
- {
- let was = ctx.disabled;
- ctx.disabled = true;
- Some(was)
- } else {
- None
- };
+ let hides_tailwind_ctx =
+ tailwind_ctx_to_push.is_none() && f.context().tailwind_context().is_some();
+ if hides_tailwind_ctx {
+ f.context_mut().hide_tailwind_context();
+ }
// NOTE: Decided before this call pushes its Tailwind context, and `MemberChain` pushes none,
// so a Tailwind call with a multiline template takes the branch below, which sorts it
@@ -77,11 +73,8 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, CallExpression<'a>> {
}
}
- // Restore the previous disabled state
- if let Some(was) = was_disabled
- && let Some(ctx) = f.context_mut().tailwind_context_mut()
- {
- ctx.disabled = was;
+ if hides_tailwind_ctx {
+ f.context_mut().pop_tailwind_context();
}
}
}
diff --git a/crates/oxc_formatter/src/print/mod.rs b/crates/oxc_formatter/src/print/mod.rs
index 5f9ea08f80..054148850f 100644
--- a/crates/oxc_formatter/src/print/mod.rs
+++ b/crates/oxc_formatter/src/print/mod.rs
@@ -89,7 +89,7 @@ use crate::{
write_trailing_comments_before,
},
string::{FormatLiteralStringToken, StringLiteralParentKind},
- tailwindcss::{tailwind_context_for_string_literal, write_tailwind_string_literal},
+ tailwindcss::{tailwind_context_for_class_list, write_tailwind_string_literal},
typecast::is_cast_target,
},
write,
@@ -1246,7 +1246,7 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, StringLiteral<'a>> {
fn write(&self, f: &mut JsFormatter<'_, 'a>) {
// Check if we're in a Tailwind context via stack (O(1) lookup)
// This handles nested string literals inside JSXAttribute/CallExpression values
- if let Some(ctx) = tailwind_context_for_string_literal(self, f) {
+ if let Some(ctx) = tailwind_context_for_class_list(self, f) {
// We're inside a Tailwind context - sort this string literal as Tailwind classes
write_tailwind_string_literal(self, ctx, f);
} else {
diff --git a/crates/oxc_formatter/src/print/template/mod.rs b/crates/oxc_formatter/src/print/template/mod.rs
index 9529e9bd9c..13d032f634 100644
--- a/crates/oxc_formatter/src/print/template/mod.rs
+++ b/crates/oxc_formatter/src/print/template/mod.rs
@@ -23,7 +23,10 @@ use crate::{
call_expression::is_test_each_pattern,
expression::is_member_expression_without_chain_wrappers,
format_node_without_trailing_comments::FormatNodeWithoutTrailingComments,
- tailwindcss::{tailwind_context_for_callee, write_tailwind_template_element},
+ tailwindcss::{
+ tailwind_context_for_callee, tailwind_context_for_class_list,
+ write_tailwind_template_element,
+ },
},
write,
};
@@ -110,16 +113,9 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TaggedTemplateExpression<'a>> {
impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TemplateElement<'a>> {
fn write(&self, f: &mut JsFormatter<'_, 'a>) {
- let source = f.source_text().text_for(self);
-
// Check if we're in a Tailwind context via the stack
// (handles JSXAttribute, CallExpression, TaggedTemplateExpression, and nested contexts)
- let tailwind_ctx = f.context().tailwind_context().copied().filter(|_| {
- // No whitespace means only one class, so no need to sort
- source.as_bytes().iter().any(|&b| b.is_ascii_whitespace())
- });
-
- if let Some(ctx) = tailwind_ctx {
+ if let Some(ctx) = tailwind_context_for_class_list(self, f) {
write_tailwind_template_element(self, ctx, f);
} else {
write!(f, text(self.value.raw.as_str()));
diff --git a/crates/oxc_formatter/src/utils/assignment_like.rs b/crates/oxc_formatter/src/utils/assignment_like.rs
index 149087621f..edd2f2e43a 100644
--- a/crates/oxc_formatter/src/utils/assignment_like.rs
+++ b/crates/oxc_formatter/src/utils/assignment_like.rs
@@ -21,10 +21,7 @@ use crate::{
format_node_without_trailing_comments::FormatNodeWithoutTrailingComments,
member_chain::is_member_call_chain,
object::{format_property_key, write_member_name},
- tailwindcss::{
- collapsed_class_list_width, tailwind_collapses_template_line_breaks,
- tailwind_context_for_callee,
- },
+ tailwindcss::{collapsed_class_list_width, tailwind_context_for_callee},
typecast::is_cast_target,
},
write,
@@ -1251,6 +1248,11 @@ fn is_short_argument(
callee: &Expression,
f: &JsFormatter,
) -> bool {
+ // Decided by `callee` alone: a Tailwind one pushes its context, a non-Tailwind one hides an outer one
+ // (except for a call printed as a `MemberChain`, which pushes none)
+ let collapses_tailwind_whitespace =
+ || tailwind_context_for_callee(callee, f).is_some_and(|ctx| !ctx.preserve_whitespace);
+
match argument {
Expression::Identifier(identifier) => identifier.name.width() <= threshold as usize,
Expression::UnaryExpression(unary_expression) => {
@@ -1265,9 +1267,7 @@ fn is_short_argument(
);
let text = formatter.clean_text(f);
- let width = if tailwind_context_for_callee(callee, f)
- .is_some_and(|ctx| !ctx.preserve_whitespace)
- {
+ let width = if collapses_tailwind_whitespace() {
// Opening and closing quotes, and the classes
2 + collapsed_class_list_width(&text[1..text.len() - 1])
} else {
@@ -1281,7 +1281,7 @@ fn is_short_argument(
// Prettier: https://github.com/prettier/prettier/blob/a043ac0d733c4d53f980aa73807a63fc914f23bd/src/language-js/print/assignment.js#L402-L405
literal.quasis.len() == 1 && {
let raw = literal.quasis[0].value.raw;
- if tailwind_collapses_template_line_breaks(Some(callee), f) {
+ if collapses_tailwind_whitespace() {
collapsed_class_list_width(&raw) <= threshold as usize
} else {
raw.width() <= threshold as usize && !raw.contains('\n')
diff --git a/crates/oxc_formatter/src/utils/object.rs b/crates/oxc_formatter/src/utils/object.rs
index c0165cf95b..f1569780fd 100644
--- a/crates/oxc_formatter/src/utils/object.rs
+++ b/crates/oxc_formatter/src/utils/object.rs
@@ -9,7 +9,7 @@ use crate::{
formatter::{JsFormatter, JsFormatterExt as _},
utils::{
string::{FormatLiteralStringToken, StringLiteralParentKind},
- tailwindcss::{tailwind_context_for_string_literal, write_tailwind_string_literal},
+ tailwindcss::{tailwind_context_for_class_list, write_tailwind_string_literal},
},
write,
};
@@ -26,7 +26,7 @@ pub fn format_property_key<'a>(
// Check if we're in a Tailwind context and the key is a string literal with multiple classes
if let AstNodeKind::StringLiteral(string) = &key.as_kind() {
- if let Some(ctx) = tailwind_context_for_string_literal(string, f) {
+ if let Some(ctx) = tailwind_context_for_class_list(string, f) {
// Reuse the existing Tailwind string literal writer
write_tailwind_string_literal(string, ctx, f);
return;
@@ -57,7 +57,7 @@ pub fn write_member_name<'a>(
f: &mut JsFormatter<'_, 'a>,
) -> usize {
if let AstNodeKind::StringLiteral(string) = &key.as_kind() {
- if let Some(ctx) = tailwind_context_for_string_literal(string, f) {
+ if let Some(ctx) = tailwind_context_for_class_list(string, f) {
// Reuse the existing Tailwind string literal writer
string.format_leading_comments(f);
let width = write_tailwind_string_literal(string, ctx, f);
diff --git a/crates/oxc_formatter/src/utils/tailwindcss.rs b/crates/oxc_formatter/src/utils/tailwindcss.rs
index 3985fd9808..f8d88aa823 100644
--- a/crates/oxc_formatter/src/utils/tailwindcss.rs
+++ b/crates/oxc_formatter/src/utils/tailwindcss.rs
@@ -26,25 +26,19 @@ use super::string::{FormatLiteralStringToken, StringLiteralParentKind};
// Detection Functions
// ============================================================================
-/// Returns the Tailwind context if we should sort classes in the given string literal.
+/// Returns the Tailwind context if we should sort classes in the given string literal or template element.
///
/// Returns `Some(ctx)` when:
-/// - We're inside a Tailwind function call context
-/// - The context is not disabled (e.g., not inside a nested non-Tailwind call)
-/// - The string contains whitespace (indicating multiple classes to sort)
-pub fn tailwind_context_for_string_literal<'a>(
- string: &AstNode<'_, 'a, StringLiteral<'a>>,
- f: &JsFormatter<'_, 'a>,
+/// - We're inside a Tailwind class context (see [`JsFormatContext::tailwind_context`])
+/// - The source contains whitespace (no whitespace means only one class, so no need to sort)
+pub fn tailwind_context_for_class_list(
+ node: &impl GetSpan,
+ f: &JsFormatter<'_, '_>,
) -> Option {
- f.context().tailwind_context().copied().filter(|ctx| {
- let text = f.source_text().text_for(string);
-
- if ctx.disabled {
- return false;
- }
-
- text.as_bytes().iter().any(|&b| b.is_ascii_whitespace())
- })
+ f.context()
+ .tailwind_context()
+ .copied()
+ .filter(|_| f.source_text().text_for(node).bytes().any(|b| b.is_ascii_whitespace()))
}
/// Checks if a JSX attribute is a Tailwind class attribute.
@@ -84,17 +78,16 @@ pub fn tailwind_context_for_jsx_attribute(
.map(|opts| TailwindContextEntry::new(opts.preserve_whitespace))
}
-/// Whether a template literal printed at this point has its whitespace, including line breaks, collapsed.
-/// - `callee` is the tag of a tagged template, or the callee of the call it's an argument of
-/// - Reads what `TemplateElement` reads: the current Tailwind context, or the one a Tailwind `callee` pushes
+/// Whether a template literal printed at this point (under `tag` when tagged) has its line breaks collapsed.
+/// - Reads what `TemplateElement` reads: the current Tailwind context, or the one a Tailwind `tag` pushes
pub fn tailwind_collapses_template_line_breaks(
- callee: Option<&Expression<'_>>,
+ tag: Option<&Expression<'_>>,
f: &JsFormatter<'_, '_>,
) -> bool {
f.context()
.tailwind_context()
.copied()
- .or_else(|| callee.and_then(|callee| tailwind_context_for_callee(callee, f)))
+ .or_else(|| tag.and_then(|tag| tailwind_context_for_callee(tag, f)))
.is_some_and(|ctx| !ctx.preserve_whitespace)
}