Skip to content

Commit 67c745e

Browse files
authored
Fix bug in attribute selector parsing (#20303)
This PR fixes a bug in the selector parser where an attribute selector followed by a type selector inside a compound selector resulted in the wrong result. Given you have this CSS: ```css [data-foo]div {} ``` Then parsing it before this PR, would result in: ```ts { kind: 'compound', nodes: [ { kind: 'selector', value: '[data-foo]div' }, ], }, ``` But with this PR, it's properly split: ```ts { kind: 'compound', nodes: [ { kind: 'selector', value: '[data-foo]' }, { kind: 'selector', value: 'div' }, ], }, ``` I also tweaked some of the comments in the parser that are unrelated, but I was there already. ## Test plan 1. Added a regression test 2. All other tests should pass
1 parent 2683903 commit 67c745e

3 files changed

Lines changed: 77 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292))
1515
- Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299))
1616
- Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302))
17+
- Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303))
1718

1819
## [4.3.2] - 2026-06-26
1920

packages/tailwindcss/src/selector-parser.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ describe('parse', () => {
445445
])
446446
})
447447

448-
it('parses universal selector after an attribute selector', () => {
448+
it('parses the universal selector after an attribute selector', () => {
449449
expect(parse('[data-foo]*')).toEqual([
450450
{
451451
kind: 'compound',
@@ -457,6 +457,42 @@ describe('parse', () => {
457457
])
458458
})
459459

460+
it('parses another attribute selector after an attribute selector', () => {
461+
expect(parse('[data-foo][data-bar]')).toEqual([
462+
{
463+
kind: 'compound',
464+
nodes: [
465+
{ kind: 'selector', value: '[data-foo]' },
466+
{ kind: 'selector', value: '[data-bar]' },
467+
],
468+
},
469+
])
470+
})
471+
472+
it('parses a type selector before an attribute selector', () => {
473+
expect(parse('div[data-foo]')).toEqual([
474+
{
475+
kind: 'compound',
476+
nodes: [
477+
{ kind: 'selector', value: 'div' },
478+
{ kind: 'selector', value: '[data-foo]' },
479+
],
480+
},
481+
])
482+
})
483+
484+
it('parses a type selector after an attribute selector', () => {
485+
expect(parse('[data-foo]div')).toEqual([
486+
{
487+
kind: 'compound',
488+
nodes: [
489+
{ kind: 'selector', value: '[data-foo]' },
490+
{ kind: 'selector', value: 'div' },
491+
],
492+
},
493+
])
494+
})
495+
460496
it('should parse selector lists as real selectors', () => {
461497
expect(parse('.foo[attr], .bar#id, .baz + .qux')).toEqual([
462498
{

packages/tailwindcss/src/selector-parser.ts

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -196,20 +196,14 @@ export function parse(input: string) {
196196
let currentChar = input.charCodeAt(i)
197197

198198
switch (currentChar) {
199-
// E.g.:
199+
// Handle selector lists
200200
//
201201
// ```css
202-
// .foo .bar
202+
// .foo, .bar {}
203203
// ^
204-
//
205-
// .foo > .bar
206-
// ^^^
207204
// ```
208205
case COMMA: {
209-
// Flush remaining buffer, mark it as a selector
210-
//
211-
// Combinators are handled separately, and functions end with `)` which
212-
// means that the `buffer` will be empty at that point.
206+
// Flush remaining buffer as a selector
213207
if (buffer.length > 0) {
214208
append(selector(buffer))
215209
buffer = ''
@@ -244,19 +238,30 @@ export function parse(input: string) {
244238
break
245239
}
246240

241+
// Handle combinators
242+
//
243+
// E.g.:
244+
//
245+
// ```css
246+
// .foo .bar
247+
// ^
248+
//
249+
// .foo > .bar
250+
// ^^^
251+
// ```
247252
case GREATER_THAN:
248253
case NEWLINE:
249254
case SPACE:
250255
case PLUS:
251256
case TAB:
252257
case TILDE: {
253-
// 1. Handle everything before the combinator as a selector
258+
// Flush remaining buffer as a selector
254259
if (buffer.length > 0) {
255260
append(selector(buffer))
256261
buffer = ''
257262
}
258263

259-
// 2. Look ahead and find the end of the combinator
264+
// Look ahead and find the end of the combinator
260265
let start = i
261266
let end = i + 1
262267
for (; end < input.length; end++) {
@@ -288,7 +293,7 @@ export function parse(input: string) {
288293
break
289294
}
290295

291-
// Start of a function call.
296+
// Start of a function call
292297
//
293298
// E.g.:
294299
//
@@ -312,7 +317,7 @@ export function parse(input: string) {
312317
let start = i + 1
313318
let nesting = 0
314319

315-
// Find the closing bracket.
320+
// Find the closing bracket
316321
for (let j = i + 1; j < input.length; j++) {
317322
peekChar = input.charCodeAt(j)
318323
if (peekChar === OPEN_PAREN) {
@@ -347,7 +352,7 @@ export function parse(input: string) {
347352
break
348353
}
349354

350-
// End of a function call.
355+
// End of a function call
351356
//
352357
// E.g.:
353358
//
@@ -356,7 +361,7 @@ export function parse(input: string) {
356361
// ^
357362
// ```
358363
case CLOSE_PAREN: {
359-
// Handle everything before the closing paren a selector
364+
// Flush remaining buffer as a selector
360365
if (buffer.length > 0) {
361366
append(selector(buffer))
362367
buffer = ''
@@ -376,7 +381,7 @@ export function parse(input: string) {
376381
break
377382
}
378383

379-
// Split compound selectors.
384+
// Split compound selectors
380385
//
381386
// E.g.:
382387
//
@@ -392,16 +397,16 @@ export function parse(input: string) {
392397
break
393398
}
394399

395-
// Handle everything before the combinator as a selector and
396-
// start a new selector
400+
// Handle everything before the combinator as a selector and start a new
401+
// selector
397402
if (buffer.length > 0) {
398403
append(selector(buffer))
399404
}
400405
buffer = input[i]
401406
break
402407
}
403408

404-
// Start of an attribute selector.
409+
// Start of an attribute selector
405410
//
406411
// NOTE: Right now we don't care about the individual parts of the
407412
// attribute selector, we just want to find the matching closing bracket.
@@ -410,16 +415,16 @@ export function parse(input: string) {
410415
// future, then we can use the `AttributeSelectorParser` here (and even
411416
// inline it if needed)
412417
case OPEN_BRACKET: {
413-
// Handle everything before the combinator as a selector
418+
// Flush remaining buffer as a selector
414419
if (buffer.length > 0) {
415420
append(selector(buffer))
421+
buffer = ''
416422
}
417-
buffer = ''
418423

419424
let start = i
420425
let nesting = 0
421426

422-
// Find the closing bracket.
427+
// Find the closing bracket
423428
for (let j = i + 1; j < input.length; j++) {
424429
peekChar = input.charCodeAt(j)
425430
if (peekChar === OPEN_BRACKET) {
@@ -435,12 +440,11 @@ export function parse(input: string) {
435440
}
436441
}
437442

438-
// Adjust `buffer` to include the string.
439-
buffer += input.slice(start, i + 1)
443+
append(selector(input.slice(start, i + 1)))
440444
break
441445
}
442446

443-
// Start of a string.
447+
// Start of a string
444448
case SINGLE_QUOTE:
445449
case DOUBLE_QUOTE: {
446450
let start = i
@@ -452,43 +456,43 @@ export function parse(input: string) {
452456
//
453457
// ```css
454458
// "This is a string with a 'quote' in it"
455-
// ^ ^ -> These are not the end of the string.
459+
// ^ ^ -> These are not the end of the string
456460
// ```
457461
for (let j = i + 1; j < input.length; j++) {
458462
peekChar = input.charCodeAt(j)
459-
// Current character is a `\` therefore the next character is escaped.
463+
// Current character is a `\` therefore the next character is escaped
460464
if (peekChar === BACKSLASH) {
461465
j += 1
462466
}
463467

464-
// End of the string.
468+
// End of the string
465469
else if (peekChar === currentChar) {
466470
i = j
467471
break
468472
}
469473
}
470474

471-
// Adjust `buffer` to include the string.
475+
// Adjust `buffer` to include the string
472476
buffer += input.slice(start, i + 1)
473477
break
474478
}
475479

476-
// Nesting `&` is always a new selector.
477-
// Universal `*` is always a new selector.
480+
// Nesting `&` is always a new selector
481+
// Universal `*` is always a new selector
478482
case AMPERSAND:
479483
case ASTERISK: {
480-
// 1. Handle everything before the combinator as a selector
484+
// Flush remaining buffer as a selector
481485
if (buffer.length > 0) {
482486
append(selector(buffer))
483487
buffer = ''
484488
}
485489

486-
// 2. Handle the `&` or `*` as a selector on its own
490+
// Handle the `&` or `*` as a selector on its own
487491
append(selector(input[i]))
488492
break
489493
}
490494

491-
// Escaped characters.
495+
// Escaped characters
492496
case BACKSLASH: {
493497
buffer += input[i] + input[i + 1]
494498
i += 1
@@ -502,7 +506,7 @@ export function parse(input: string) {
502506
}
503507
}
504508

505-
// Collect the remainder as a word
509+
// Collect the remainder as a selector
506510
if (buffer.length > 0) {
507511
append(selector(buffer))
508512
}

0 commit comments

Comments
 (0)