From e738daede118091b2624d23f7cf164dfb1366773 Mon Sep 17 00:00:00 2001 From: Yuji Sugiura <6259812+leaysgur@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:52:45 +0900 Subject: [PATCH] fix(formatter): quote keys added in an HTML event handler with single quotes (#28380) Regardless of the location, the attribute value must be quoted by single quote. --- apps/oxfmt/test/api/embedded_languages.test.ts | 15 +++++++++++++++ crates/oxc_formatter/src/formatter/context.rs | 13 ++++++++++++- crates/oxc_formatter/src/lib.rs | 3 +++ crates/oxc_formatter/src/print/mod.rs | 4 ++-- crates/oxc_formatter/src/utils/string.rs | 15 ++++++--------- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/oxfmt/test/api/embedded_languages.test.ts b/apps/oxfmt/test/api/embedded_languages.test.ts index 561928e97b..dbcf8270f5 100644 --- a/apps/oxfmt/test/api/embedded_languages.test.ts +++ b/apps/oxfmt/test/api/embedded_languages.test.ts @@ -269,6 +269,21 @@ describe("Embedded languages", () => { expect(result.code).toMatchSnapshot(); }); + // js-in-html: a quote the formatter adds (a key under `quoteProps: consistent`) is `'`, as every string there, + // so the attribute's `"` needs no `"` + it("should add single quotes to keys in an event handler attribute", async () => { + const input = `\ + + +`; + const result = await format("index.html", input, { quoteProps: "consistent" }); + expect(result.errors).toStrictEqual([]); + expect(result.code).toBe(`\ + + +`); + }); + // (css-in-html)-in-js: For now, HTML is handled by Prettier, so CSS is also handled by Prettier too. // The CSS `Doc` carries a trailing space after `prop: ` that only materializes when the value fits flat; // when the value breaks, it must not leak (the Doc→IR conversion maps it to a pending space, dropped at the break). diff --git a/crates/oxc_formatter/src/formatter/context.rs b/crates/oxc_formatter/src/formatter/context.rs index 3366128701..d9897aa25e 100644 --- a/crates/oxc_formatter/src/formatter/context.rs +++ b/crates/oxc_formatter/src/formatter/context.rs @@ -3,7 +3,11 @@ use oxc_formatter_core::{FormatElement, SourceText}; use oxc_span::{GetSpan, SourceType, Span}; use rustc_hash::FxHashMap; -use crate::{JsEmbeddedIn, options::JsFormatOptions, utils::assignment_like::AssignmentLikeLayout}; +use crate::{ + JsEmbeddedIn, + options::{JsFormatOptions, QuoteStyle}, + utils::assignment_like::AssignmentLikeLayout, +}; use super::Comments; @@ -254,6 +258,13 @@ impl<'ast> JsFormatContext<'ast> { self.embedded_in == Some(JsEmbeddedIn::HtmlEventHandler) } + /// The quote a string prints in (Prettier's `printString`), the formatter's own too (a quoted property key): + /// the configured one, but `'` in an HTML attribute. + /// A JSX attribute and a directive have their own (`FormatLiteralStringToken`). + pub(crate) fn string_quote_style(&self) -> QuoteStyle { + if self.in_html_attribute() { QuoteStyle::Single } else { self.options.quote_style } + } + /// 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) { diff --git a/crates/oxc_formatter/src/lib.rs b/crates/oxc_formatter/src/lib.rs index 9bdd4f3347..aa22e3eec4 100644 --- a/crates/oxc_formatter/src/lib.rs +++ b/crates/oxc_formatter/src/lib.rs @@ -183,6 +183,9 @@ pub fn format_fragment<'a>( // NOTE: Since this options is just the preferred quote (not a forced), // this may not be enough for all cases. // But it seems fine for almost all cases, so leave it for now. + // TODO: When Vue formats natively, share the HTML event handler's rule instead + // (`JsFormatContext::string_quote_style`, forced `'` as Prettier's `__isInHtmlAttribute`): + // a string holding more `'` than `"` prints `"` here, which the attribute escapes as `"`. let options = JsFormatOptions { quote_style: QuoteStyle::Single, ..options }; // A js-in-xxx fragment never owns file envelopes (BOM / front matter) diff --git a/crates/oxc_formatter/src/print/mod.rs b/crates/oxc_formatter/src/print/mod.rs index 84ae3f72d5..853004ccd0 100644 --- a/crates/oxc_formatter/src/print/mod.rs +++ b/crates/oxc_formatter/src/print/mod.rs @@ -144,7 +144,7 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, IdentifierName<'a>> { | AstNodes::TSEnumMember(_) ); if is_property_key_parent && f.context().is_quote_needed() { - let quote_str = f.options().quote_style.as_str(); + let quote_str = f.context().string_quote_style().as_str(); write!(f, [quote_str, text, quote_str]); } else { write!(f, text); @@ -1223,7 +1223,7 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, NumericLiteral<'a>> { }; if should_quote { - let quote_str = f.options().quote_style.as_str(); + let quote_str = f.context().string_quote_style().as_str(); write!(f, [quote_str, text(f.allocator().alloc_str(&formatted)), quote_str]); return; } diff --git a/crates/oxc_formatter/src/utils/string.rs b/crates/oxc_formatter/src/utils/string.rs index 8b210e5d67..3c41884ab6 100644 --- a/crates/oxc_formatter/src/utils/string.rs +++ b/crates/oxc_formatter/src/utils/string.rs @@ -47,15 +47,12 @@ impl<'a> FormatLiteralStringToken<'a> { let source_type = f.context().source_type(); // In an HTML attribute, `'` always (Prettier's `__isInHtmlAttribute`), but a directive as usual - let in_html_attribute = !self.jsx - && self.parent_kind != StringLiteralParentKind::Directive - && f.context().in_html_attribute(); - let chosen_quote_style = if in_html_attribute { - QuoteStyle::Single - } else if self.jsx { - options.jsx_quote_style + let (chosen_quote_style, force_chosen_quote) = if self.jsx { + (options.jsx_quote_style, false) + } else if self.parent_kind == StringLiteralParentKind::Directive { + (options.quote_style, false) } else { - options.quote_style + (f.context().string_quote_style(), f.context().in_html_attribute()) }; let is_quote_needed = match options.quote_properties { @@ -67,7 +64,7 @@ impl<'a> FormatLiteralStringToken<'a> { let string_cleaner = LiteralStringNormalizer { token: *self, chosen_quote_style, - force_chosen_quote: in_html_attribute, + force_chosen_quote, is_quote_needed, allocator: f.allocator(), };