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.
This commit is contained in:
Yuji Sugiura authored and GitHub committed 2026-10-30 16:52:45 +09:00
1 parent 2e7a629be8
commit e738daede1
5 files changed
+38 -12

No files matched your search

@@ -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 = `\
<button onclick="f({a: 1, 'b-c': 2})">x</button>
<button onclick="f({1: 1, 'b-c': 2})">x</button>
`;
const result = await format("index.html", input, { quoteProps: "consistent" });
expect(result.errors).toStrictEqual([]);
expect(result.code).toBe(`\
<button onclick="f({ 'a': 1, 'b-c': 2 })">x</button>
<button onclick="f({ '1': 1, 'b-c': 2 })">x</button>
`);
});
// (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).
+12 -1
View File
@@ -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) {
+3
View File
@@ -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 `&quot;`.
let options = JsFormatOptions { quote_style: QuoteStyle::Single, ..options };
// A js-in-xxx fragment never owns file envelopes (BOM / front matter)
+2 -2
View File
@@ -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;
}
+6 -9
View File
@@ -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(),
};