fix(formatter): don't sort templates in nested non-Tailwind calls

- Hide the Tailwind context with a `None` stack entry instead of a `disabled` flag,
  so templates and strings in their `${}` honor it like strings did
- Note the divergence from prettier-plugin-tailwindcss#426 in AGENTS.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
leaysgurandClaude Opus 5.5 committed 2026-11-04 15:51:23 +09:00
1 parent 255c5cca26
commit b65809dcde
9 files changed
+94 -81

No files matched your search

+36 -3
View File
@@ -697,7 +697,8 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
const result = await format("test.ts", input, options); const result = await format("test.ts", input, options);
// The line breaks are collapsed, so the template is not hugged as multiline, // 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(` expect(result.code).toMatchInlineSnapshot(`
"const a = clsx( "const a = clsx(
\`flex items-center justify-between p-4 \${active ? "bg-white" : "bg-black"} text-black hover:bg-gray-100\`, \`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 = const eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee =
clsx(\`flex p-4\`); clsx(\`flex p-4\`);
const f = clsx({ const f = clsx({
kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk: kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk: foo(
foo(\`flex p-4\`), \`p-4 flex\`,
),
}); });
" "
`); `);
@@ -1005,6 +1007,37 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
expect(result.errors).toStrictEqual([]); 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 = <div className={x.split(\`a b\`).length ? "p-4 flex" : ""} />;
const D = <div className={foo(\`p-4
flex\`)} />;
`;
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 = <div className={x.split(\`a b\`).length ? "flex p-4" : ""} />;
const D = (
<div
className={foo(\`p-4
flex\`)}
/>
);
"
`);
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 () => { it("should preserve escape sequences in nested call expressions", async () => {
// Various escape sequences should be preserved in nested calls // Various escape sequences should be preserved in nested calls
const input = `const A = <div className={clsx( const input = `const A = <div className={clsx(
+4 -1
View File
@@ -54,9 +54,12 @@ After changing AST shapes or the generators, regenerate with `just ast`, never h
### Sort Tailwind CSS ### Sort Tailwind CSS
- Backed by `prettier-plugin-tailwindcss` - Backed by `prettier-plugin-tailwindcss`, but not fully compatible
- Classes are collected during IR construction and sorted in one batch when the IR is stringified - Classes are collected during IR construction and sorted in one batch when the IR is stringified
- Requires the session's `TailwindSorter` service (the sort itself is delegated to the host) - Requires the session's `TailwindSorter` service (the sort itself is delegated to the host)
- Covered by the Oxfmt API tests (`apps/oxfmt/test/api/sort_tailwindcss.test.ts`), since only the host sorts
- Deliberately unlike the plugin, nothing in the arguments of a nested non-Tailwind call is sorted
(tailwindlabs/prettier-plugin-tailwindcss#426): `cn(x.includes("a b"))` keeps `"a b"`, which the plugin rewrites
### Embedded language formatting ### Embedded language formatting
+13 -18
View File
@@ -31,11 +31,6 @@ pub struct TailwindContextEntry {
/// Whether this is the last quasi in a template literal. /// Whether this is the last quasi in a template literal.
/// Used for template element boundary detection. /// Used for template element boundary detection.
pub is_last_quasi: bool, pub is_last_quasi: bool,
/// Whether Tailwind sorting is disabled in this context.
/// Used to prevent sorting strings inside nested non-Tailwind call expressions.
/// For example, in `classNames("a", x.includes("\n") ? "b" : "c")`, the `"\n"`
/// inside `includes()` should NOT be sorted as a Tailwind class.
pub disabled: bool,
} }
impl TailwindContextEntry { impl TailwindContextEntry {
@@ -48,7 +43,6 @@ impl TailwindContextEntry {
quasi_after_has_leading_ws: true, // Default: can collapse quasi_after_has_leading_ws: true, // Default: can collapse
is_first_quasi: true, is_first_quasi: true,
is_last_quasi: true, is_last_quasi: true,
disabled: false,
} }
} }
@@ -66,7 +60,6 @@ impl TailwindContextEntry {
quasi_after_has_leading_ws, quasi_after_has_leading_ws,
is_first_quasi: true, is_first_quasi: true,
is_last_quasi: true, is_last_quasi: true,
disabled: false,
} }
} }
@@ -100,9 +93,9 @@ pub struct JsFormatContext<'ast> {
/// structures (e.g., `{ a: { "b-c": 1 } }` where only the inner object needs quoted keys). /// structures (e.g., `{ a: { "b-c": 1 } }` where only the inner object needs quoted keys).
quote_needed_stack: Vec<bool>, quote_needed_stack: Vec<bool>,
/// Stack tracking whether we're inside a Tailwind class context. /// Stack tracking whether we're inside a Tailwind class context, read by [`Self::tailwind_context`].
/// When non-empty, StringLiterals should be sorted as Tailwind classes. /// - `None` hides the context below it, inside a nested non-Tailwind call
tailwind_context_stack: Vec<TailwindContextEntry>, tailwind_context_stack: Vec<Option<TailwindContextEntry>>,
/// Set only for an embedded program (see [`crate::format_to_ir`]): /// Set only for an embedded program (see [`crate::format_to_ir`]):
/// its sole statement prints without its semicolon. /// its sole statement prints without its semicolon.
@@ -268,7 +261,13 @@ impl<'ast> JsFormatContext<'ast> {
/// Push a Tailwind context entry onto the stack. /// Push a Tailwind context entry onto the stack.
/// Call this when entering a JSXAttribute or CallExpression with Tailwind class context. /// Call this when entering a JSXAttribute or CallExpression with Tailwind class context.
pub fn push_tailwind_context(&mut self, entry: TailwindContextEntry) { 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. /// Pop a Tailwind context entry from the stack.
@@ -278,13 +277,9 @@ impl<'ast> JsFormatContext<'ast> {
} }
/// Get the current Tailwind context, if any. /// 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> { pub fn tailwind_context(&self) -> Option<&TailwindContextEntry> {
self.tailwind_context_stack.last() self.tailwind_context_stack.last().and_then(Option::as_ref)
}
/// 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()
} }
} }
@@ -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) // 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); let tailwind_ctx_to_push = tailwind_context_for_callee(&self.callee, f);
// For nested non-Tailwind calls inside a Tailwind context, disable sorting // For nested non-Tailwind calls inside a Tailwind context, hide the context
// to prevent sorting strings inside the nested call's arguments. // 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") // (e.g., `classNames("a", x.includes("\n") ? "b" : "c")` - don't sort "\n")
let was_disabled = if tailwind_ctx_to_push.is_none() let hides_tailwind_ctx =
&& let Some(ctx) = f.context_mut().tailwind_context_mut() tailwind_ctx_to_push.is_none() && f.context().tailwind_context().is_some();
{ if hides_tailwind_ctx {
let was = ctx.disabled; f.context_mut().hide_tailwind_context();
ctx.disabled = true; }
Some(was)
} else {
None
};
// NOTE: Decided before this call pushes its Tailwind context, and `MemberChain` pushes none, // 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 // 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 hides_tailwind_ctx {
if let Some(was) = was_disabled f.context_mut().pop_tailwind_context();
&& let Some(ctx) = f.context_mut().tailwind_context_mut()
{
ctx.disabled = was;
} }
} }
} }
+2 -2
View File
@@ -89,7 +89,7 @@ use crate::{
write_trailing_comments_before, write_trailing_comments_before,
}, },
string::{FormatLiteralStringToken, StringLiteralParentKind}, 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, typecast::is_cast_target,
}, },
write, write,
@@ -1246,7 +1246,7 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, StringLiteral<'a>> {
fn write(&self, f: &mut JsFormatter<'_, 'a>) { fn write(&self, f: &mut JsFormatter<'_, 'a>) {
// Check if we're in a Tailwind context via stack (O(1) lookup) // Check if we're in a Tailwind context via stack (O(1) lookup)
// This handles nested string literals inside JSXAttribute/CallExpression values // 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 // We're inside a Tailwind context - sort this string literal as Tailwind classes
write_tailwind_string_literal(self, ctx, f); write_tailwind_string_literal(self, ctx, f);
} else { } else {
@@ -23,7 +23,10 @@ use crate::{
call_expression::is_test_each_pattern, call_expression::is_test_each_pattern,
expression::is_member_expression_without_chain_wrappers, expression::is_member_expression_without_chain_wrappers,
format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, 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, write,
}; };
@@ -110,16 +113,9 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TaggedTemplateExpression<'a>> {
impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TemplateElement<'a>> { impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TemplateElement<'a>> {
fn write(&self, f: &mut JsFormatter<'_, '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 // Check if we're in a Tailwind context via the stack
// (handles JSXAttribute, CallExpression, TaggedTemplateExpression, and nested contexts) // (handles JSXAttribute, CallExpression, TaggedTemplateExpression, and nested contexts)
let tailwind_ctx = f.context().tailwind_context().copied().filter(|_| { if let Some(ctx) = tailwind_context_for_class_list(self, f) {
// 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 {
write_tailwind_template_element(self, ctx, f); write_tailwind_template_element(self, ctx, f);
} else { } else {
write!(f, text(self.value.raw.as_str())); write!(f, text(self.value.raw.as_str()));
@@ -21,10 +21,7 @@ use crate::{
format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, format_node_without_trailing_comments::FormatNodeWithoutTrailingComments,
member_chain::is_member_call_chain, member_chain::is_member_call_chain,
object::{format_property_key, write_member_name}, object::{format_property_key, write_member_name},
tailwindcss::{ tailwindcss::{collapsed_class_list_width, tailwind_context_for_callee},
collapsed_class_list_width, tailwind_collapses_template_line_breaks,
tailwind_context_for_callee,
},
typecast::is_cast_target, typecast::is_cast_target,
}, },
write, write,
@@ -1251,6 +1248,11 @@ fn is_short_argument(
callee: &Expression, callee: &Expression,
f: &JsFormatter, f: &JsFormatter,
) -> bool { ) -> 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 { match argument {
Expression::Identifier(identifier) => identifier.name.width() <= threshold as usize, Expression::Identifier(identifier) => identifier.name.width() <= threshold as usize,
Expression::UnaryExpression(unary_expression) => { Expression::UnaryExpression(unary_expression) => {
@@ -1265,9 +1267,7 @@ fn is_short_argument(
); );
let text = formatter.clean_text(f); let text = formatter.clean_text(f);
let width = if tailwind_context_for_callee(callee, f) let width = if collapses_tailwind_whitespace() {
.is_some_and(|ctx| !ctx.preserve_whitespace)
{
// Opening and closing quotes, and the classes // Opening and closing quotes, and the classes
2 + collapsed_class_list_width(&text[1..text.len() - 1]) 2 + collapsed_class_list_width(&text[1..text.len() - 1])
} else { } 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 // Prettier: https://github.com/prettier/prettier/blob/a043ac0d733c4d53f980aa73807a63fc914f23bd/src/language-js/print/assignment.js#L402-L405
literal.quasis.len() == 1 && { literal.quasis.len() == 1 && {
let raw = literal.quasis[0].value.raw; 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 collapsed_class_list_width(&raw) <= threshold as usize
} else { } else {
raw.width() <= threshold as usize && !raw.contains('\n') raw.width() <= threshold as usize && !raw.contains('\n')
+3 -3
View File
@@ -9,7 +9,7 @@ use crate::{
formatter::{JsFormatter, JsFormatterExt as _}, formatter::{JsFormatter, JsFormatterExt as _},
utils::{ utils::{
string::{FormatLiteralStringToken, StringLiteralParentKind}, string::{FormatLiteralStringToken, StringLiteralParentKind},
tailwindcss::{tailwind_context_for_string_literal, write_tailwind_string_literal}, tailwindcss::{tailwind_context_for_class_list, write_tailwind_string_literal},
}, },
write, 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 // 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 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 // Reuse the existing Tailwind string literal writer
write_tailwind_string_literal(string, ctx, f); write_tailwind_string_literal(string, ctx, f);
return; return;
@@ -57,7 +57,7 @@ pub fn write_member_name<'a>(
f: &mut JsFormatter<'_, 'a>, f: &mut JsFormatter<'_, 'a>,
) -> usize { ) -> usize {
if let AstNodeKind::StringLiteral(string) = &key.as_kind() { 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 // Reuse the existing Tailwind string literal writer
string.format_leading_comments(f); string.format_leading_comments(f);
let width = write_tailwind_string_literal(string, ctx, f); let width = write_tailwind_string_literal(string, ctx, f);
+14 -21
View File
@@ -26,25 +26,19 @@ use super::string::{FormatLiteralStringToken, StringLiteralParentKind};
// Detection Functions // 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: /// Returns `Some(ctx)` when:
/// - We're inside a Tailwind function call context /// - We're inside a Tailwind class context (see [`JsFormatContext::tailwind_context`])
/// - The context is not disabled (e.g., not inside a nested non-Tailwind call) /// - The source contains whitespace (no whitespace means only one class, so no need to sort)
/// - The string contains whitespace (indicating multiple classes to sort) pub fn tailwind_context_for_class_list(
pub fn tailwind_context_for_string_literal<'a>( node: &impl GetSpan,
string: &AstNode<'_, 'a, StringLiteral<'a>>, f: &JsFormatter<'_, '_>,
f: &JsFormatter<'_, 'a>,
) -> Option<TailwindContextEntry> { ) -> Option<TailwindContextEntry> {
f.context().tailwind_context().copied().filter(|ctx| { f.context()
let text = f.source_text().text_for(string); .tailwind_context()
.copied()
if ctx.disabled { .filter(|_| f.source_text().text_for(node).bytes().any(|b| b.is_ascii_whitespace()))
return false;
}
text.as_bytes().iter().any(|&b| b.is_ascii_whitespace())
})
} }
/// Checks if a JSX attribute is a Tailwind class attribute. /// 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)) .map(|opts| TailwindContextEntry::new(opts.preserve_whitespace))
} }
/// Whether a template literal printed at this point has its whitespace, including line breaks, collapsed. /// Whether a template literal printed at this point (under `tag` when tagged) has its 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 `tag` pushes
/// - Reads what `TemplateElement` reads: the current Tailwind context, or the one a Tailwind `callee` pushes
pub fn tailwind_collapses_template_line_breaks( pub fn tailwind_collapses_template_line_breaks(
callee: Option<&Expression<'_>>, tag: Option<&Expression<'_>>,
f: &JsFormatter<'_, '_>, f: &JsFormatter<'_, '_>,
) -> bool { ) -> bool {
f.context() f.context()
.tailwind_context() .tailwind_context()
.copied() .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) .is_some_and(|ctx| !ctx.preserve_whitespace)
} }