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(),
};