Skip to content

Commit 2b6f3c2

Browse files
authored
fix: Preserve escape sequences when sorting JS string literals (#461)
* Preserve escape sequences when sorting JS string literals When sorting class strings inside a JS string literal whose outer quote is fixed by the surrounding context (e.g. a Vue `:class="cn('...')"` attribute), the sorter operated on the cooked value and then wrote it back as raw without re-encoding, dropping JS escapes like `\'` and producing invalid JavaScript that failed the next parse. Sort the raw source representation directly so escape sequences ride along with their tokens, mirroring how `sortTemplateLiteral` already handles quasis. This also removes the need to discriminate JS literals from JSX attribute values (where backslashes are literal and divergence between raw and cooked is driven by HTML entity decoding, not escapes). The trade-off: literals that use a whitespace escape sequence as the class separator look like one non-whitespace token in raw form, so we skip sorting them. This shape has no realistic callers and rewriting it would re-introduce the cooked → raw re-encoding we just eliminated. * Update changelog entry for issue #461
1 parent 410eb88 commit 2b6f3c2

3 files changed

Lines changed: 88 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Preserve escape sequences when sorting JS string literals so escaped quotes inside attribute-bound class strings (e.g. Vue `:class="cn('...')"`) round-trip correctly (#461)
1113

1214
## [0.8.0] - 2026-04-27
1315

src/index.ts

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { defineTransform } from './transform.js'
1313
import type { StringChange, TransformerEnv } from './types'
1414
import { spliceChangesIntoString, visit, type Path } from './utils.js'
1515

16-
const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g
1716
function tryParseAngularAttribute(value: string, env: TransformerEnv) {
1817
try {
1918
return prettierParserAngular.parsers.__ng_directive.parse(value, env.options)
@@ -415,43 +414,36 @@ function sortStringLiteral(
415414
collapseWhitespace?: false | { start: boolean; end: boolean }
416415
},
417416
) {
418-
let result = sortClasses(node.value, {
419-
env,
420-
removeDuplicates,
421-
collapseWhitespace,
422-
})
423-
424-
let didChange = result !== node.value
425-
426-
if (!didChange) return false
427-
428-
node.value = result
429-
430-
// Preserve the original escaping level for the new content
417+
// Sort the raw source representation directly
418+
// so escape sequences ride along with their tokens.
419+
// This mirrors how `sortTemplateLiteral` handles quasis
420+
// and lets us avoid a fragile cooked → raw re-encoding pass.
421+
//
422+
// Trade-off:
423+
// Literals that use a whitespace escape sequence (e.g. `\n` as a JS escape) as the class separator
424+
// look like a single non-whitespace token in raw form, so we skip sorting them.
425+
// Rewriting the raw from the sorted cooked value would require re-introducing the cooked → raw re-encoding,
426+
// and with it the JSX/HTML-entity discrimination problem.
431427
let raw = node.extra?.raw ?? node.raw
432428
let quote = raw[0]
433-
let originalRawContent = raw.slice(1, -1)
434-
let originalValue = node.extra?.rawValue ?? node.value
429+
let rawContent = raw.slice(1, -1)
435430

436-
if (node.extra) {
437-
// The original list has ecapes so we ensure that the sorted list also
438-
// maintains those by replacing backslashes from escape sequences.
439-
//
440-
// It seems that TypeScript-based ASTs don't need this special handling
441-
// which is why this is guarded inside the `node.extra` check
442-
if (originalRawContent !== originalValue && originalValue.includes('\\')) {
443-
result = result.replace(ESCAPE_SEQUENCE_PATTERN, '\\\\$1')
444-
}
431+
let sortedRaw = sortClasses(rawContent, { env, removeDuplicates, collapseWhitespace })
432+
if (sortedRaw === rawContent) return false
445433

446-
// JavaScript (StringLiteral)
447-
node.extra = {
448-
...node.extra,
449-
rawValue: result,
450-
raw: quote + result + quote,
451-
}
434+
// Reuse the raw sort when raw and cooked are byte-identical (no escapes).
435+
// Avoids a second `getClassOrder` pass, the dominant cost in `sortClasses`.
436+
let sortedCooked =
437+
rawContent === node.value
438+
? sortedRaw
439+
: sortClasses(node.value, { env, removeDuplicates, collapseWhitespace })
440+
node.value = sortedCooked
441+
442+
let newRaw = quote + sortedRaw + quote
443+
if (node.extra) {
444+
node.extra = { ...node.extra, rawValue: sortedCooked, raw: newRaw }
452445
} else {
453-
// TypeScript (Literal)
454-
node.raw = quote + result + quote
446+
node.raw = newRaw
455447
}
456448

457449
return true

tests/format.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,27 @@ describe('regex matching', () => {
231231
expect(result).toEqual('<div :data-classes="`p-0 sm:p-0`"></div>')
232232
})
233233

234+
test('does not re-escape JSX attribute values that use HTML entities', async ({ expect }) => {
235+
// JSX attribute values are pass-through: HTML entity references must
236+
// survive sorting unchanged, not be decoded and re-escaped.
237+
let result = await format(`;<div className="sm:p-0 foo-&#34;bar&#34; p-0" />`, {
238+
parser: 'babel',
239+
})
240+
241+
expect(result).toEqual(`;<div className="foo-&#34;bar&#34; p-0 sm:p-0" />`)
242+
})
243+
244+
test('preserves escaped quotes inside JS string in Vue :class', async ({ expect }) => {
245+
// Inside a Vue :class="..." attribute the JS string MUST stay single-quoted
246+
// (outer attribute already uses "), so an inner ' has to remain escaped as \'.
247+
let input = `<template><button :class="cn('text-sm [&_svg:not([class*=\\'size-\\'])]:size-4 flex p-2', props.class)"></button></template>`
248+
let expected = `<template><button :class="cn('flex p-2 text-sm [&_svg:not([class*=\\'size-\\'])]:size-4', props.class)"></button></template>`
249+
250+
let result = await format(input, { parser: 'vue' })
251+
252+
expect(result).toEqual(expected)
253+
})
254+
234255
test('works with Angular property bindings', async ({ expect }) => {
235256
let result = await format('<div [dataClasses]="`sm:p-0 p-0`"></div>', {
236257
parser: 'angular',
@@ -288,3 +309,42 @@ describe('regex matching', () => {
288309
})
289310
})
290311
})
312+
313+
describe('escape sequences in JS string literals', () => {
314+
test('babel: JSX expression container with escaped quote in arbitrary variant', async ({
315+
expect,
316+
}) => {
317+
// Exercises sortStringLiteral via transformJavaScript directly
318+
// (distinct from the Vue path that goes through transformDynamicJsAttribute).
319+
let input = `;<div className={"text-sm [&_svg:not([class*=\\'size-\\'])]:size-4 flex p-2"} />`
320+
let expected = `;<div className={"flex p-2 text-sm [&_svg:not([class*=\\'size-\\'])]:size-4"} />`
321+
322+
let result = await format(input, { parser: 'babel' })
323+
324+
expect(result).toEqual(expected)
325+
})
326+
327+
test('typescript: literal via tailwindFunctions preserves escaped quote', async ({ expect }) => {
328+
// Exercises the `node.raw =` branch (TS Literal without `node.extra`).
329+
let input = `let x = tw("text-sm [&_svg:not([class*=\\'size-\\'])]:size-4 flex p-2")`
330+
let expected = `let x = tw("flex p-2 text-sm [&_svg:not([class*=\\'size-\\'])]:size-4")`
331+
332+
let result = await format(input, {
333+
parser: 'typescript',
334+
tailwindFunctions: ['tw'],
335+
})
336+
337+
expect(result).toEqual(expected)
338+
})
339+
340+
test('babel: JS whitespace escape as class separator is not sorted', async ({ expect }) => {
341+
// Documented trade-off: a string like "sm:p-0\np-0" has no whitespace in its raw form,
342+
// so sortClasses sees it as a single token and skips it.
343+
let input = `;<div className={"sm:p-0\\np-0"} />`
344+
let expected = `;<div className={'sm:p-0\\np-0'} />`
345+
346+
let result = await format(input, { parser: 'babel' })
347+
348+
expect(result).toEqual(expected)
349+
})
350+
})

0 commit comments

Comments
 (0)