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
This commit is contained in:
parent
2683903b86
commit
67c745efde
3 changed files with 77 additions and 36 deletions
|
|
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292))
|
||||
- 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))
|
||||
- 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))
|
||||
- Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303))
|
||||
|
||||
## [4.3.2] - 2026-06-26
|
||||
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ describe('parse', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('parses universal selector after an attribute selector', () => {
|
||||
it('parses the universal selector after an attribute selector', () => {
|
||||
expect(parse('[data-foo]*')).toEqual([
|
||||
{
|
||||
kind: 'compound',
|
||||
|
|
@ -457,6 +457,42 @@ describe('parse', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('parses another attribute selector after an attribute selector', () => {
|
||||
expect(parse('[data-foo][data-bar]')).toEqual([
|
||||
{
|
||||
kind: 'compound',
|
||||
nodes: [
|
||||
{ kind: 'selector', value: '[data-foo]' },
|
||||
{ kind: 'selector', value: '[data-bar]' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses a type selector before an attribute selector', () => {
|
||||
expect(parse('div[data-foo]')).toEqual([
|
||||
{
|
||||
kind: 'compound',
|
||||
nodes: [
|
||||
{ kind: 'selector', value: 'div' },
|
||||
{ kind: 'selector', value: '[data-foo]' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses a type selector after an attribute selector', () => {
|
||||
expect(parse('[data-foo]div')).toEqual([
|
||||
{
|
||||
kind: 'compound',
|
||||
nodes: [
|
||||
{ kind: 'selector', value: '[data-foo]' },
|
||||
{ kind: 'selector', value: 'div' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should parse selector lists as real selectors', () => {
|
||||
expect(parse('.foo[attr], .bar#id, .baz + .qux')).toEqual([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -196,20 +196,14 @@ export function parse(input: string) {
|
|||
let currentChar = input.charCodeAt(i)
|
||||
|
||||
switch (currentChar) {
|
||||
// E.g.:
|
||||
// Handle selector lists
|
||||
//
|
||||
// ```css
|
||||
// .foo .bar
|
||||
// .foo, .bar {}
|
||||
// ^
|
||||
//
|
||||
// .foo > .bar
|
||||
// ^^^
|
||||
// ```
|
||||
case COMMA: {
|
||||
// Flush remaining buffer, mark it as a selector
|
||||
//
|
||||
// Combinators are handled separately, and functions end with `)` which
|
||||
// means that the `buffer` will be empty at that point.
|
||||
// Flush remaining buffer as a selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
buffer = ''
|
||||
|
|
@ -244,19 +238,30 @@ export function parse(input: string) {
|
|||
break
|
||||
}
|
||||
|
||||
// Handle combinators
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```css
|
||||
// .foo .bar
|
||||
// ^
|
||||
//
|
||||
// .foo > .bar
|
||||
// ^^^
|
||||
// ```
|
||||
case GREATER_THAN:
|
||||
case NEWLINE:
|
||||
case SPACE:
|
||||
case PLUS:
|
||||
case TAB:
|
||||
case TILDE: {
|
||||
// 1. Handle everything before the combinator as a selector
|
||||
// Flush remaining buffer as a selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
buffer = ''
|
||||
}
|
||||
|
||||
// 2. Look ahead and find the end of the combinator
|
||||
// Look ahead and find the end of the combinator
|
||||
let start = i
|
||||
let end = i + 1
|
||||
for (; end < input.length; end++) {
|
||||
|
|
@ -288,7 +293,7 @@ export function parse(input: string) {
|
|||
break
|
||||
}
|
||||
|
||||
// Start of a function call.
|
||||
// Start of a function call
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
|
|
@ -312,7 +317,7 @@ export function parse(input: string) {
|
|||
let start = i + 1
|
||||
let nesting = 0
|
||||
|
||||
// Find the closing bracket.
|
||||
// Find the closing bracket
|
||||
for (let j = i + 1; j < input.length; j++) {
|
||||
peekChar = input.charCodeAt(j)
|
||||
if (peekChar === OPEN_PAREN) {
|
||||
|
|
@ -347,7 +352,7 @@ export function parse(input: string) {
|
|||
break
|
||||
}
|
||||
|
||||
// End of a function call.
|
||||
// End of a function call
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
|
|
@ -356,7 +361,7 @@ export function parse(input: string) {
|
|||
// ^
|
||||
// ```
|
||||
case CLOSE_PAREN: {
|
||||
// Handle everything before the closing paren a selector
|
||||
// Flush remaining buffer as a selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
buffer = ''
|
||||
|
|
@ -376,7 +381,7 @@ export function parse(input: string) {
|
|||
break
|
||||
}
|
||||
|
||||
// Split compound selectors.
|
||||
// Split compound selectors
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
|
|
@ -392,8 +397,8 @@ export function parse(input: string) {
|
|||
break
|
||||
}
|
||||
|
||||
// Handle everything before the combinator as a selector and
|
||||
// start a new selector
|
||||
// Handle everything before the combinator as a selector and start a new
|
||||
// selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
}
|
||||
|
|
@ -401,7 +406,7 @@ export function parse(input: string) {
|
|||
break
|
||||
}
|
||||
|
||||
// Start of an attribute selector.
|
||||
// Start of an attribute selector
|
||||
//
|
||||
// NOTE: Right now we don't care about the individual parts of the
|
||||
// attribute selector, we just want to find the matching closing bracket.
|
||||
|
|
@ -410,16 +415,16 @@ export function parse(input: string) {
|
|||
// future, then we can use the `AttributeSelectorParser` here (and even
|
||||
// inline it if needed)
|
||||
case OPEN_BRACKET: {
|
||||
// Handle everything before the combinator as a selector
|
||||
// Flush remaining buffer as a selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
buffer = ''
|
||||
}
|
||||
buffer = ''
|
||||
|
||||
let start = i
|
||||
let nesting = 0
|
||||
|
||||
// Find the closing bracket.
|
||||
// Find the closing bracket
|
||||
for (let j = i + 1; j < input.length; j++) {
|
||||
peekChar = input.charCodeAt(j)
|
||||
if (peekChar === OPEN_BRACKET) {
|
||||
|
|
@ -435,12 +440,11 @@ export function parse(input: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Adjust `buffer` to include the string.
|
||||
buffer += input.slice(start, i + 1)
|
||||
append(selector(input.slice(start, i + 1)))
|
||||
break
|
||||
}
|
||||
|
||||
// Start of a string.
|
||||
// Start of a string
|
||||
case SINGLE_QUOTE:
|
||||
case DOUBLE_QUOTE: {
|
||||
let start = i
|
||||
|
|
@ -452,43 +456,43 @@ export function parse(input: string) {
|
|||
//
|
||||
// ```css
|
||||
// "This is a string with a 'quote' in it"
|
||||
// ^ ^ -> These are not the end of the string.
|
||||
// ^ ^ -> These are not the end of the string
|
||||
// ```
|
||||
for (let j = i + 1; j < input.length; j++) {
|
||||
peekChar = input.charCodeAt(j)
|
||||
// Current character is a `\` therefore the next character is escaped.
|
||||
// Current character is a `\` therefore the next character is escaped
|
||||
if (peekChar === BACKSLASH) {
|
||||
j += 1
|
||||
}
|
||||
|
||||
// End of the string.
|
||||
// End of the string
|
||||
else if (peekChar === currentChar) {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust `buffer` to include the string.
|
||||
// Adjust `buffer` to include the string
|
||||
buffer += input.slice(start, i + 1)
|
||||
break
|
||||
}
|
||||
|
||||
// Nesting `&` is always a new selector.
|
||||
// Universal `*` is always a new selector.
|
||||
// Nesting `&` is always a new selector
|
||||
// Universal `*` is always a new selector
|
||||
case AMPERSAND:
|
||||
case ASTERISK: {
|
||||
// 1. Handle everything before the combinator as a selector
|
||||
// Flush remaining buffer as a selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
buffer = ''
|
||||
}
|
||||
|
||||
// 2. Handle the `&` or `*` as a selector on its own
|
||||
// Handle the `&` or `*` as a selector on its own
|
||||
append(selector(input[i]))
|
||||
break
|
||||
}
|
||||
|
||||
// Escaped characters.
|
||||
// Escaped characters
|
||||
case BACKSLASH: {
|
||||
buffer += input[i] + input[i + 1]
|
||||
i += 1
|
||||
|
|
@ -502,7 +506,7 @@ export function parse(input: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Collect the remainder as a word
|
||||
// Collect the remainder as a selector
|
||||
if (buffer.length > 0) {
|
||||
append(selector(buffer))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue