From fa81d697fe572a10ac150d18964a093a7a874081 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 25 Sep 2026 21:32:41 +0200 Subject: [PATCH] Improve style invalidation performance of `group-*` and `peer-*` variants (#20513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # For humans, by @RobinMalfait ## TL;DR Some people ran into performance issues with the current `group-*` variant because of the `*` inside of the selector. Take the `group-focus-visible:flex` class for example, this generates: ```css .group-focus-visible\:flex:is(:where(.group):focus-visible *) { display: flex; } ``` This slightly rewrites the selector by maintaining the same functionality, but increasing performance because the browser has to do fewer style recalculations. That same class now produces: ```css :is(:where(.group):focus-visible .group-focus-visible\\:flex) { display: flex; } ``` # For AI, generated by AI This PR changes the selectors generated for `group-*` and `peer-*` variants so browsers do less style recalculation when a group or peer changes state (e.g. on focus or hover). Which elements match, and with what specificity, stays the same, except for one deliberately accepted edge case involving `@namespace` (see below). ```css /* Before */ .group-focus\:flex:is(:where(.group):focus *) { display: flex; } .peer-focus\:flex:is(:where(.peer):focus ~ *) { display: flex; } /* After */ :is(:where(.group):focus .group-focus\:flex) { display: flex; } :is(:where(.peer):focus ~ .peer-focus\:flex) { display: flex; } ``` ## Why In the old form, the subject inside `:is(…)` is `*`. When `.group` changes state, Chromium has to recalculate styles for **every** descendant of the group (or every following sibling of the peer), not just the elements that use the utility. With the target itself in that position, the browser can narrow invalidation down to elements matching the target. ## Performance Synthetic benchmark: toggle focus on a group/peer, flush style after each change, and measure only a non-layout property (`outline-color`) to isolate invalidation. Apple M5 Pro, macOS arm64. **Elements recalculated per focus/blur** (Chromium 151, `UpdateLayoutTree` `elementCount`, including the focused element): | Scenario | Before | After | | ------------------------------------------- | -----: | ------: | | Group: 100 targets among 10,000 descendants | 10,001 | **101** | | Peer: 20 targets among 2,000 siblings | 2,001 | **21** | **Median time per focus/blur** (sparse: 1% of elements carry the utility): | Engine | Group: before → after | Peer: before → after | | ------------ | ------------------------- | ------------------------- | | Chromium 151 | 2.169 → **0.130 ms** (~17×) | 0.635 → **0.192 ms** (~3.3×) | | Firefox 153 | 0.500 → 0.350 ms | 0.650 → 0.600 ms | | WebKit 26.5 | 0.750 → 0.750 ms | 0.450 → 0.450 ms | **Dense case** (every element carries the utility): no meaningful difference in any engine, because every element needs recalculation anyway. Firefox's dense group case was ~5% slower (2.775 → 2.925 ms). Everything else was within noise. | Engine | Group dense: before → after | Peer dense: before → after | | ------------ | --------------------------- | -------------------------- | | Chromium 151 | 4.027 → 3.971 ms | 18.095 → 18.121 ms | | Firefox 153 | 2.775 → 2.925 ms | 43.900 → 44.025 ms | | WebKit 26.5 | 7.550 → 7.425 ms | 35.325 → 35.100 ms | These are micro-benchmarks of style updates, not page-load or frame-rate numbers. The real-world gain depends on DOM size and how many elements inside a group/peer use the variant. The biggest win is the common case: a large group containing only a handful of `group-*` targets. ## Do the selectors behave the same? Yes. For a group condition `G` and a target `&`: - **Before:** matches `&` and has an ancestor matching `G` - **After:** has an ancestor matching `G` and matches `&` `peer-*` follows the same reasoning with `~`. Details: - **Specificity is unchanged.** `:is()` takes the specificity of its argument, so before was `spec(&) + spec(G)` and after is `spec(G) + spec(&)`. - **`&` is used, not the utility class**, so `@apply`, `@variant`, `*:group-*`, `[&_p]:group-*`, and other variants that change the target keep working. Complex parents such as `.foo .bar { @apply peer-focus:flex }` keep `:is(…)` semantics during nesting: `:is(P ~ :is(.foo .bar))`. - **`&` appears only once**, so stacked variants grow the selector linearly. A unit test with 12 stacked variants guards against exponential growth. - **The selectors are also shorter:** 6 bytes of wrapping instead of 7. - **The outer `:is(…)`** keeps compound variants such as `has-group-*`, `not-group-*`, and `in-group-*` equivalent. For example, `has-group-*` can still match when the group sits outside the element carrying the utility. **Accepted edge case:** if a stylesheet declares a default `@namespace`, the old trailing `*` limited matches to elements in that namespace. Inside compound variants such as `group-group-*` or `has-group-*`, the new selector no longer does, so an SVG element (e.g. inside `foreignObject`) can now count as the inner group. Appending `:is(*)` to the target would restore the old behavior with no performance cost, but it makes every selector longer for a combination (`@namespace` + mixed namespaces + compound group variants) that is very unlikely in practice. We can add it back if anyone runs into this. There's one known browser quirk this PR doesn't change: Chromium doesn't invalidate `has-group-*` when the focused group is an ancestor *outside* the element. That happens with both the old and new selectors. ## Test plan - [x] Updated unit test snapshots for the new selector shape - [x] New unit test that bounds selector size with 12 stacked variants - [x] New browser tests in `packages/tailwindcss/tests/ui.spec.ts`, run in Chromium, Firefox, and WebKit. They cover `group-*`/`peer-*` focus and blur, `@apply` inside a complex selector, specificity, stacked groups in either order, and compound `group-peer-*`/`peer-group-*`. The pre-PR selectors pass all of them too, so behavior is unchanged - [x] `pnpm run test` and `pnpm run test:ui` pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + .../tailwindcss/src/compat/plugin-api.test.ts | 14 +- packages/tailwindcss/src/index.test.ts | 40 +++--- packages/tailwindcss/src/intellisense.test.ts | 6 +- packages/tailwindcss/src/prefix.test.ts | 2 +- packages/tailwindcss/src/variants.test.ts | 127 ++++++++++-------- packages/tailwindcss/src/variants.ts | 13 +- packages/tailwindcss/tests/ui.spec.ts | 114 ++++++++++++++++ 8 files changed, 226 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 479fca86f..e5e6f6f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466)) - Sort `min-*`, `max-*`, and container query variants with decimal values numerically (e.g. `min-[40.25rem]` before `min-[40.5rem]`) ([#20512](https://github.com/tailwindlabs/tailwindcss/pull/20512)) - Ensure CSS comments ending with `\*/` are closed correctly instead of swallowing the CSS that follows (e.g. `/* C:\temp\*/`) ([#20508](https://github.com/tailwindlabs/tailwindcss/pull/20508)) +- Improve style invalidation performance of `group-*` and `peer-*` variants ([#20513](https://github.com/tailwindlabs/tailwindcss/pull/20513)) ## [4.3.3] - 2026-07-16 diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index dd6928b23..5fcacb5cd 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1630,7 +1630,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } @@ -1744,7 +1744,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -1784,7 +1784,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -1827,12 +1827,12 @@ describe('addVariant', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hocus\\:flex) { display: flex; } } - .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -1964,7 +1964,7 @@ describe('addVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-optional\\:flex:is(:where(.group):optional *), .group-optional\\/foo\\:flex:is(:where(.group\\/foo):optional *), .peer-optional\\:flex:is(:where(.peer):optional ~ *), .optional\\:flex:optional { + :is(:where(.group):optional .group-optional\\:flex), :is(:where(.group\\/foo):optional .group-optional\\/foo\\:flex), :is(:where(.peer):optional ~ .peer-optional\\:flex), .optional\\:flex:optional { display: flex; } } @@ -2903,7 +2903,7 @@ describe('matchVariant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-optional-\\[test\\]\\:flex:is(:where(.group):optional:has(test) :where(.group) *), .group-optional-\\[test\\]\\/foo\\:flex:is(:where(.group\\/foo):optional:has(test) :where(.group\\/foo) *), .peer-optional-\\[test\\]\\:flex:is(:where(.peer):optional:has(test) :where(.peer) ~ *), .optional-\\[test\\]\\:flex:optional:has(test) .optional-\\[test\\]\\:flex { + :is(:where(.group):optional:has(test) :where(.group) .group-optional-\\[test\\]\\:flex), :is(:where(.group\\/foo):optional:has(test) :where(.group\\/foo) .group-optional-\\[test\\]\\/foo\\:flex), :is(:where(.peer):optional:has(test) :where(.peer) ~ .peer-optional-\\[test\\]\\:flex), .optional-\\[test\\]\\:flex:optional:has(test) .optional-\\[test\\]\\:flex { display: flex; } } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 5eebd1716..7f4a0eef3 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1444,42 +1444,42 @@ describe('sorting', () => { ).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } @media (hover: hover) { - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + :is(:where(.peer):hover ~ .peer-hover\\:flex) { display: flex; } @media (hover: hover) { - .group-hover\\:peer-hover\\:flex:is(:where(.group):hover *):is(:where(.peer):hover ~ *), .peer-hover\\:group-hover\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):hover *) { + :is(:where(.peer):hover ~ :is(:where(.group):hover .group-hover\\:peer-hover\\:flex)), :is(:where(.group):hover :is(:where(.peer):hover ~ .peer-hover\\:group-hover\\:flex)) { display: flex; } } - .group-focus\\:peer-hover\\:flex:is(:where(.group):focus *):is(:where(.peer):hover ~ *), .peer-hover\\:group-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):focus *) { + :is(:where(.peer):hover ~ :is(:where(.group):focus .group-focus\\:peer-hover\\:flex)), :is(:where(.group):focus :is(:where(.peer):hover ~ .peer-hover\\:group-focus\\:flex)) { display: flex; } } - .peer-focus\\:flex:is(:where(.peer):focus ~ *) { + :is(:where(.peer):focus ~ .peer-focus\\:flex) { display: flex; } @media (hover: hover) { - .group-hover\\:peer-focus\\:flex:is(:where(.group):hover *):is(:where(.peer):focus ~ *), .peer-focus\\:group-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):hover *) { + :is(:where(.peer):focus ~ :is(:where(.group):hover .group-hover\\:peer-focus\\:flex)), :is(:where(.group):hover :is(:where(.peer):focus ~ .peer-focus\\:group-hover\\:flex)) { display: flex; } } - .group-focus\\:peer-focus\\:flex:is(:where(.group):focus *):is(:where(.peer):focus ~ *), .peer-focus\\:group-focus\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):focus *) { + :is(:where(.peer):focus ~ :is(:where(.group):focus .group-focus\\:peer-focus\\:flex)), :is(:where(.group):focus :is(:where(.peer):focus ~ .peer-focus\\:group-focus\\:flex)) { display: flex; } @@ -3601,7 +3601,7 @@ describe('plugins', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } @@ -3636,7 +3636,7 @@ describe('plugins', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -3674,7 +3674,7 @@ describe('plugins', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):hover .group-hocus\\:flex), :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -3715,12 +3715,12 @@ describe('plugins', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hocus\\:flex) { display: flex; } } - .group-hocus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-hocus\\:flex) { display: flex; } @@ -4199,7 +4199,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } @@ -4294,7 +4294,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-selected\\:underline:is(:where(.group)[data-selected] *), .selected\\:underline[data-selected] { + :is(:where(.group)[data-selected] .group-selected\\:underline), .selected\\:underline[data-selected] { text-decoration-line: underline; } } @@ -4322,7 +4322,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:underline:is(:is(:where(.group):hover, :where(.group):focus) *), .hocus\\:underline:hover, .hocus\\:underline:focus { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:underline), .hocus\\:underline:hover, .hocus\\:underline:focus { text-decoration-line: underline; } } @@ -4353,7 +4353,7 @@ describe('@custom-variant', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .group-hocus\\:underline:is(:where(.group):hover *), .group-hocus\\:underline:is(:where(.group):focus *), .hocus\\:underline:hover, .hocus\\:underline:focus { + :is(:where(.group):hover .group-hocus\\:underline), :is(:where(.group):focus .group-hocus\\:underline), .hocus\\:underline:hover, .hocus\\:underline:focus { text-decoration-line: underline; } } @@ -4458,12 +4458,12 @@ describe('@custom-variant', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:underline:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hocus\\:underline) { text-decoration-line: underline; } } - .group-hocus\\:underline:is(:where(.group):focus *) { + :is(:where(.group):focus .group-hocus\\:underline) { text-decoration-line: underline; } @@ -4503,7 +4503,7 @@ describe('@custom-variant', () => { " @layer utilities { @media (hover: hover) { - .group-hocus\\:underline:is(:where(.group):hover *), .hocus\\:underline:hover { + :is(:where(.group):hover .group-hocus\\:underline), .hocus\\:underline:hover { text-decoration-line: underline; } } diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index a31ec5d74..63f9e7576 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -97,9 +97,9 @@ test('getVariants compound', async () => { ] expect(list).toEqual([ - ['@media (hover: hover) { &:is(:where(.group):hover *) }'], - ['@media (hover: hover) { &:is(:where(.group\\/sidebar):hover *) }'], - ['@media (hover: hover) { &:is(:where(.group):is(:where(.group):hover *) *) }'], + ['@media (hover: hover) { :is(:where(.group):hover &) }'], + ['@media (hover: hover) { :is(:where(.group\\/sidebar):hover &) }'], + ['@media (hover: hover) { :is(:is(:where(.group):hover :where(.group)) &) }'], [], [], ]) diff --git a/packages/tailwindcss/src/prefix.test.ts b/packages/tailwindcss/src/prefix.test.ts index 799cb22fe..ded304cad 100644 --- a/packages/tailwindcss/src/prefix.test.ts +++ b/packages/tailwindcss/src/prefix.test.ts @@ -38,7 +38,7 @@ test('utilities must be prefixed', async () => { } @media (hover: hover) { - .tw\\:group-hover\\:flex:is(:where(.tw\\:group):hover *), .tw\\:peer-hover\\:flex:is(:where(.tw\\:peer):hover ~ *) { + :is(:where(.tw\\:group):hover .tw\\:group-hover\\:flex), :is(:where(.tw\\:peer):hover ~ .tw\\:peer-hover\\:flex) { display: flex; } diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 2304326fc..37f9ebbea 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -186,7 +186,7 @@ test('after', async () => { test('first', async () => { expect(await run(['first:flex', 'group-first:flex', 'peer-first:flex'])).toMatchInlineSnapshot(` " - .group-first\\:flex:is(:where(.group):first-child *), .peer-first\\:flex:is(:where(.peer):first-child ~ *), .first\\:flex:first-child { + :is(:where(.group):first-child .group-first\\:flex), :is(:where(.peer):first-child ~ .peer-first\\:flex), .first\\:flex:first-child { display: flex; } " @@ -197,7 +197,7 @@ test('first', async () => { test('last', async () => { expect(await run(['last:flex', 'group-last:flex', 'peer-last:flex'])).toMatchInlineSnapshot(` " - .group-last\\:flex:is(:where(.group):last-child *), .peer-last\\:flex:is(:where(.peer):last-child ~ *), .last\\:flex:last-child { + :is(:where(.group):last-child .group-last\\:flex), :is(:where(.peer):last-child ~ .peer-last\\:flex), .last\\:flex:last-child { display: flex; } " @@ -208,7 +208,7 @@ test('last', async () => { test('only', async () => { expect(await run(['only:flex', 'group-only:flex', 'peer-only:flex'])).toMatchInlineSnapshot(` " - .group-only\\:flex:is(:where(.group):only-child *), .peer-only\\:flex:is(:where(.peer):only-child ~ *), .only\\:flex:only-child { + :is(:where(.group):only-child .group-only\\:flex), :is(:where(.peer):only-child ~ .peer-only\\:flex), .only\\:flex:only-child { display: flex; } " @@ -219,7 +219,7 @@ test('only', async () => { test('odd', async () => { expect(await run(['odd:flex', 'group-odd:flex', 'peer-odd:flex'])).toMatchInlineSnapshot(` " - .group-odd\\:flex:is(:where(.group):nth-child(odd) *), .peer-odd\\:flex:is(:where(.peer):nth-child(odd) ~ *), .odd\\:flex:nth-child(odd) { + :is(:where(.group):nth-child(odd) .group-odd\\:flex), :is(:where(.peer):nth-child(odd) ~ .peer-odd\\:flex), .odd\\:flex:nth-child(odd) { display: flex; } " @@ -230,7 +230,7 @@ test('odd', async () => { test('even', async () => { expect(await run(['even:flex', 'group-even:flex', 'peer-even:flex'])).toMatchInlineSnapshot(` " - .group-even\\:flex:is(:where(.group):nth-child(2n) *), .peer-even\\:flex:is(:where(.peer):nth-child(2n) ~ *), .even\\:flex:nth-child(2n) { + :is(:where(.group):nth-child(2n) .group-even\\:flex), :is(:where(.peer):nth-child(2n) ~ .peer-even\\:flex), .even\\:flex:nth-child(2n) { display: flex; } " @@ -242,7 +242,7 @@ test('first-of-type', async () => { expect(await run(['first-of-type:flex', 'group-first-of-type:flex', 'peer-first-of-type:flex'])) .toMatchInlineSnapshot(` " - .group-first-of-type\\:flex:is(:where(.group):first-of-type *), .peer-first-of-type\\:flex:is(:where(.peer):first-of-type ~ *), .first-of-type\\:flex:first-of-type { + :is(:where(.group):first-of-type .group-first-of-type\\:flex), :is(:where(.peer):first-of-type ~ .peer-first-of-type\\:flex), .first-of-type\\:flex:first-of-type { display: flex; } " @@ -254,7 +254,7 @@ test('last-of-type', async () => { expect(await run(['last-of-type:flex', 'group-last-of-type:flex', 'peer-last-of-type:flex'])) .toMatchInlineSnapshot(` " - .group-last-of-type\\:flex:is(:where(.group):last-of-type *), .peer-last-of-type\\:flex:is(:where(.peer):last-of-type ~ *), .last-of-type\\:flex:last-of-type { + :is(:where(.group):last-of-type .group-last-of-type\\:flex), :is(:where(.peer):last-of-type ~ .peer-last-of-type\\:flex), .last-of-type\\:flex:last-of-type { display: flex; } " @@ -266,7 +266,7 @@ test('only-of-type', async () => { expect(await run(['only-of-type:flex', 'group-only-of-type:flex', 'peer-only-of-type:flex'])) .toMatchInlineSnapshot(` " - .group-only-of-type\\:flex:is(:where(.group):only-of-type *), .peer-only-of-type\\:flex:is(:where(.peer):only-of-type ~ *), .only-of-type\\:flex:only-of-type { + :is(:where(.group):only-of-type .group-only-of-type\\:flex), :is(:where(.peer):only-of-type ~ .peer-only-of-type\\:flex), .only-of-type\\:flex:only-of-type { display: flex; } " @@ -278,7 +278,7 @@ test('visited', async () => { expect(await run(['visited:flex', 'group-visited:flex', 'peer-visited:flex'])) .toMatchInlineSnapshot(` " - .group-visited\\:flex:is(:where(.group):visited *), .peer-visited\\:flex:is(:where(.peer):visited ~ *), .visited\\:flex:visited { + :is(:where(.group):visited .group-visited\\:flex), :is(:where(.peer):visited ~ .peer-visited\\:flex), .visited\\:flex:visited { display: flex; } " @@ -290,7 +290,7 @@ test('target', async () => { expect(await run(['target:flex', 'group-target:flex', 'peer-target:flex'])) .toMatchInlineSnapshot(` " - .group-target\\:flex:is(:where(.group):target *), .peer-target\\:flex:is(:where(.peer):target ~ *), .target\\:flex:target { + :is(:where(.group):target .group-target\\:flex), :is(:where(.peer):target ~ .peer-target\\:flex), .target\\:flex:target { display: flex; } " @@ -302,7 +302,7 @@ test('open', async () => { expect(await run(['open:flex', 'group-open:flex', 'peer-open:flex', 'not-open:flex'])) .toMatchInlineSnapshot(` " - .not-open\\:flex:not(:is([open], :popover-open, :open)), .group-open\\:flex:is(:where(.group):is([open], :popover-open, :open) *), .peer-open\\:flex:is(:where(.peer):is([open], :popover-open, :open) ~ *), .open\\:flex:is([open], :popover-open, :open) { + .not-open\\:flex:not(:is([open], :popover-open, :open)), :is(:where(.group):is([open], :popover-open, :open) .group-open\\:flex), :is(:where(.peer):is([open], :popover-open, :open) ~ .peer-open\\:flex), .open\\:flex:is([open], :popover-open, :open) { display: flex; } " @@ -314,7 +314,7 @@ test('default', async () => { expect(await run(['default:flex', 'group-default:flex', 'peer-default:flex'])) .toMatchInlineSnapshot(` " - .group-default\\:flex:is(:where(.group):default *), .peer-default\\:flex:is(:where(.peer):default ~ *), .default\\:flex:default { + :is(:where(.group):default .group-default\\:flex), :is(:where(.peer):default ~ .peer-default\\:flex), .default\\:flex:default { display: flex; } " @@ -326,7 +326,7 @@ test('checked', async () => { expect(await run(['checked:flex', 'group-checked:flex', 'peer-checked:flex'])) .toMatchInlineSnapshot(` " - .group-checked\\:flex:is(:where(.group):checked *), .peer-checked\\:flex:is(:where(.peer):checked ~ *), .checked\\:flex:checked { + :is(:where(.group):checked .group-checked\\:flex), :is(:where(.peer):checked ~ .peer-checked\\:flex), .checked\\:flex:checked { display: flex; } " @@ -338,7 +338,7 @@ test('indeterminate', async () => { expect(await run(['indeterminate:flex', 'group-indeterminate:flex', 'peer-indeterminate:flex'])) .toMatchInlineSnapshot(` " - .group-indeterminate\\:flex:is(:where(.group):indeterminate *), .peer-indeterminate\\:flex:is(:where(.peer):indeterminate ~ *), .indeterminate\\:flex:indeterminate { + :is(:where(.group):indeterminate .group-indeterminate\\:flex), :is(:where(.peer):indeterminate ~ .peer-indeterminate\\:flex), .indeterminate\\:flex:indeterminate { display: flex; } " @@ -355,7 +355,7 @@ test('placeholder-shown', async () => { ]), ).toMatchInlineSnapshot(` " - .group-placeholder-shown\\:flex:is(:where(.group):placeholder-shown *), .peer-placeholder-shown\\:flex:is(:where(.peer):placeholder-shown ~ *), .placeholder-shown\\:flex:placeholder-shown { + :is(:where(.group):placeholder-shown .group-placeholder-shown\\:flex), :is(:where(.peer):placeholder-shown ~ .peer-placeholder-shown\\:flex), .placeholder-shown\\:flex:placeholder-shown { display: flex; } " @@ -367,7 +367,7 @@ test('autofill', async () => { expect(await run(['autofill:flex', 'group-autofill:flex', 'peer-autofill:flex'])) .toMatchInlineSnapshot(` " - .group-autofill\\:flex:is(:where(.group):autofill *), .peer-autofill\\:flex:is(:where(.peer):autofill ~ *) { + :is(:where(.group):autofill .group-autofill\\:flex), :is(:where(.peer):autofill ~ .peer-autofill\\:flex) { display: flex; } @@ -383,7 +383,7 @@ test('optional', async () => { expect(await run(['optional:flex', 'group-optional:flex', 'peer-optional:flex'])) .toMatchInlineSnapshot(` " - .group-optional\\:flex:is(:where(.group):optional *), .peer-optional\\:flex:is(:where(.peer):optional ~ *), .optional\\:flex:optional { + :is(:where(.group):optional .group-optional\\:flex), :is(:where(.peer):optional ~ .peer-optional\\:flex), .optional\\:flex:optional { display: flex; } " @@ -395,7 +395,7 @@ test('required', async () => { expect(await run(['required:flex', 'group-required:flex', 'peer-required:flex'])) .toMatchInlineSnapshot(` " - .group-required\\:flex:is(:where(.group):required *), .peer-required\\:flex:is(:where(.peer):required ~ *), .required\\:flex:required { + :is(:where(.group):required .group-required\\:flex), :is(:where(.peer):required ~ .peer-required\\:flex), .required\\:flex:required { display: flex; } " @@ -406,7 +406,7 @@ test('required', async () => { test('valid', async () => { expect(await run(['valid:flex', 'group-valid:flex', 'peer-valid:flex'])).toMatchInlineSnapshot(` " - .group-valid\\:flex:is(:where(.group):valid *), .peer-valid\\:flex:is(:where(.peer):valid ~ *), .valid\\:flex:valid { + :is(:where(.group):valid .group-valid\\:flex), :is(:where(.peer):valid ~ .peer-valid\\:flex), .valid\\:flex:valid { display: flex; } " @@ -418,7 +418,7 @@ test('invalid', async () => { expect(await run(['invalid:flex', 'group-invalid:flex', 'peer-invalid:flex'])) .toMatchInlineSnapshot(` " - .group-invalid\\:flex:is(:where(.group):invalid *), .peer-invalid\\:flex:is(:where(.peer):invalid ~ *), .invalid\\:flex:invalid { + :is(:where(.group):invalid .group-invalid\\:flex), :is(:where(.peer):invalid ~ .peer-invalid\\:flex), .invalid\\:flex:invalid { display: flex; } " @@ -430,7 +430,7 @@ test('user-valid', async () => { expect(await run(['user-valid:flex', 'group-user-valid:flex', 'peer-user-valid:flex'])) .toMatchInlineSnapshot(` " - .group-user-valid\\:flex:is(:where(.group):user-valid *), .peer-user-valid\\:flex:is(:where(.peer):user-valid ~ *) { + :is(:where(.group):user-valid .group-user-valid\\:flex), :is(:where(.peer):user-valid ~ .peer-user-valid\\:flex) { display: flex; } @@ -446,7 +446,7 @@ test('user-invalid', async () => { expect(await run(['user-invalid:flex', 'group-user-invalid:flex', 'peer-user-invalid:flex'])) .toMatchInlineSnapshot(` " - .group-user-invalid\\:flex:is(:where(.group):user-invalid *), .peer-user-invalid\\:flex:is(:where(.peer):user-invalid ~ *) { + :is(:where(.group):user-invalid .group-user-invalid\\:flex), :is(:where(.peer):user-invalid ~ .peer-user-invalid\\:flex) { display: flex; } @@ -462,7 +462,7 @@ test('in-range', async () => { expect(await run(['in-range:flex', 'group-in-range:flex', 'peer-in-range:flex'])) .toMatchInlineSnapshot(` " - .group-in-range\\:flex:is(:where(.group):in-range *), .peer-in-range\\:flex:is(:where(.peer):in-range ~ *), .in-range\\:flex:in-range { + :is(:where(.group):in-range .group-in-range\\:flex), :is(:where(.peer):in-range ~ .peer-in-range\\:flex), .in-range\\:flex:in-range { display: flex; } " @@ -474,7 +474,7 @@ test('out-of-range', async () => { expect(await run(['out-of-range:flex', 'group-out-of-range:flex', 'peer-out-of-range:flex'])) .toMatchInlineSnapshot(` " - .group-out-of-range\\:flex:is(:where(.group):out-of-range *), .peer-out-of-range\\:flex:is(:where(.peer):out-of-range ~ *), .out-of-range\\:flex:out-of-range { + :is(:where(.group):out-of-range .group-out-of-range\\:flex), :is(:where(.peer):out-of-range ~ .peer-out-of-range\\:flex), .out-of-range\\:flex:out-of-range { display: flex; } " @@ -486,7 +486,7 @@ test('read-only', async () => { expect(await run(['read-only:flex', 'group-read-only:flex', 'peer-read-only:flex'])) .toMatchInlineSnapshot(` " - .group-read-only\\:flex:is(:where(.group):read-only *), .peer-read-only\\:flex:is(:where(.peer):read-only ~ *), .read-only\\:flex:read-only { + :is(:where(.group):read-only .group-read-only\\:flex), :is(:where(.peer):read-only ~ .peer-read-only\\:flex), .read-only\\:flex:read-only { display: flex; } " @@ -497,7 +497,7 @@ test('read-only', async () => { test('empty', async () => { expect(await run(['empty:flex', 'group-empty:flex', 'peer-empty:flex'])).toMatchInlineSnapshot(` " - .group-empty\\:flex:is(:where(.group):empty *), .peer-empty\\:flex:is(:where(.peer):empty ~ *), .empty\\:flex:empty { + :is(:where(.group):empty .group-empty\\:flex), :is(:where(.peer):empty ~ .peer-empty\\:flex), .empty\\:flex:empty { display: flex; } " @@ -509,7 +509,7 @@ test('focus-within', async () => { expect(await run(['focus-within:flex', 'group-focus-within:flex', 'peer-focus-within:flex'])) .toMatchInlineSnapshot(` " - .group-focus-within\\:flex:is(:where(.group):focus-within *), .peer-focus-within\\:flex:is(:where(.peer):focus-within ~ *), .focus-within\\:flex:focus-within { + :is(:where(.group):focus-within .group-focus-within\\:flex), :is(:where(.peer):focus-within ~ .peer-focus-within\\:flex), .focus-within\\:flex:focus-within { display: flex; } " @@ -521,7 +521,7 @@ test('hover', async () => { expect(await run(['hover:flex', 'group-hover:flex', 'peer-hover:flex'])).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *), .peer-hover\\:flex:is(:where(.peer):hover ~ *), .hover\\:flex:hover { + :is(:where(.group):hover .group-hover\\:flex), :is(:where(.peer):hover ~ .peer-hover\\:flex), .hover\\:flex:hover { display: flex; } } @@ -533,7 +533,7 @@ test('hover', async () => { test('focus', async () => { expect(await run(['focus:flex', 'group-focus:flex', 'peer-focus:flex'])).toMatchInlineSnapshot(` " - .group-focus\\:flex:is(:where(.group):focus *), .peer-focus\\:flex:is(:where(.peer):focus ~ *), .focus\\:flex:focus { + :is(:where(.group):focus .group-focus\\:flex), :is(:where(.peer):focus ~ .peer-focus\\:flex), .focus\\:flex:focus { display: flex; } " @@ -545,12 +545,12 @@ test('group-hover group-focus sorting', async () => { expect(await run(['group-hover:flex', 'group-focus:flex'])).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } " @@ -558,23 +558,36 @@ test('group-hover group-focus sorting', async () => { expect(await run(['group-focus:flex', 'group-hover:flex'])).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } " `) }) +test('stacking group and peer variants does not duplicate the target selector', async () => { + let candidate = Array.from( + { length: 12 }, + (_, i) => `${i % 2 === 0 ? 'group' : 'peer'}-data-active/name${i}`, + ).join(':') + + let output = await run([`${candidate}:flex`]) + expect(output).toContain('display: flex') + // Repeating `&` in each variant makes the generated selector grow + // exponentially. This many variants should still produce a small rule. + expect(output.length).toBeLessThan(2_000) +}) + test('focus-visible', async () => { expect(await run(['focus-visible:flex', 'group-focus-visible:flex', 'peer-focus-visible:flex'])) .toMatchInlineSnapshot(` " - .group-focus-visible\\:flex:is(:where(.group):focus-visible *), .peer-focus-visible\\:flex:is(:where(.peer):focus-visible ~ *), .focus-visible\\:flex:focus-visible { + :is(:where(.group):focus-visible .group-focus-visible\\:flex), :is(:where(.peer):focus-visible ~ .peer-focus-visible\\:flex), .focus-visible\\:flex:focus-visible { display: flex; } " @@ -586,7 +599,7 @@ test('active', async () => { expect(await run(['active:flex', 'group-active:flex', 'peer-active:flex'])) .toMatchInlineSnapshot(` " - .group-active\\:flex:is(:where(.group):active *), .peer-active\\:flex:is(:where(.peer):active ~ *), .active\\:flex:active { + :is(:where(.group):active .group-active\\:flex), :is(:where(.peer):active ~ .peer-active\\:flex), .active\\:flex:active { display: flex; } " @@ -598,7 +611,7 @@ test('enabled', async () => { expect(await run(['enabled:flex', 'group-enabled:flex', 'peer-enabled:flex'])) .toMatchInlineSnapshot(` " - .group-enabled\\:flex:is(:where(.group):enabled *), .peer-enabled\\:flex:is(:where(.peer):enabled ~ *), .enabled\\:flex:enabled { + :is(:where(.group):enabled .group-enabled\\:flex), :is(:where(.peer):enabled ~ .peer-enabled\\:flex), .enabled\\:flex:enabled { display: flex; } " @@ -610,7 +623,7 @@ test('disabled', async () => { expect(await run(['disabled:flex', 'group-disabled:flex', 'peer-disabled:flex'])) .toMatchInlineSnapshot(` " - .group-disabled\\:flex:is(:where(.group):disabled *), .peer-disabled\\:flex:is(:where(.peer):disabled ~ *), .disabled\\:flex:disabled { + :is(:where(.group):disabled .group-disabled\\:flex), :is(:where(.peer):disabled ~ .peer-disabled\\:flex), .disabled\\:flex:disabled { display: flex; } " @@ -621,7 +634,7 @@ test('disabled', async () => { test('inert', async () => { expect(await run(['inert:flex', 'group-inert:flex', 'peer-inert:flex'])).toMatchInlineSnapshot(` " - .group-inert\\:flex:is(:where(.group):is([inert], [inert] *) *), .peer-inert\\:flex:is(:where(.peer):is([inert], [inert] *) ~ *), .inert\\:flex:is([inert], [inert] *) { + :is(:where(.group):is([inert], [inert] *) .group-inert\\:flex), :is(:where(.peer):is([inert], [inert] *) ~ .peer-inert\\:flex), .inert\\:flex:is([inert], [inert] *) { display: flex; } " @@ -640,17 +653,17 @@ test('group-[...]', async () => { ]), ).toMatchInlineSnapshot(` " - .group-\\[\\&_p\\]\\:flex:is(:where(.group) p *), .group-\\[\\&\\:hover\\]\\:group-\\[\\&_p\\]\\:flex:is(:where(.group):hover *):is(:where(.group) p *) { + :is(:where(.group) p .group-\\[\\&_p\\]\\:flex), :is(:where(.group) p :is(:where(.group):hover .group-\\[\\&\\:hover\\]\\:group-\\[\\&_p\\]\\:flex)) { display: flex; } @media (hover: hover) { - .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover, .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { + :is(:where(.group) p .group-\\[\\&_p\\]\\:hover\\:flex):hover, :is(:where(.group) p .hover\\:group-\\[\\&_p\\]\\:flex:hover) { display: flex; } @media (hover: hover) { - .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { + :is(:where(.group) p .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover):hover { display: flex; } } @@ -692,22 +705,22 @@ test('group-*', async () => { ).toMatchInlineSnapshot(` " @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *) { + :is(:where(.group):hover .group-hover\\:flex) { display: flex; } } - .group-focus\\:flex:is(:where(.group):focus *) { + :is(:where(.group):focus .group-focus\\:flex) { display: flex; } @media (hover: hover) { - .group-focus\\:group-hover\\:flex:is(:where(.group):focus *):is(:where(.group):hover *), .group-hover\\:group-focus\\:flex:is(:where(.group):hover *):is(:where(.group):focus *) { + :is(:where(.group):hover :is(:where(.group):focus .group-focus\\:group-hover\\:flex)), :is(:where(.group):focus :is(:where(.group):hover .group-hover\\:group-focus\\:flex)) { display: flex; } } - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + :is(:is(:where(.group):hover, :where(.group):focus) .group-hocus\\:flex) { display: flex; } " @@ -742,12 +755,12 @@ test('peer-[...]', async () => { ]), ).toMatchInlineSnapshot(` " - .peer-\\[\\&_p\\]\\:flex:is(:where(.peer) p ~ *), .peer-\\[\\&\\:hover\\]\\:peer-\\[\\&_p\\]\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer) p ~ *) { + :is(:where(.peer) p ~ .peer-\\[\\&_p\\]\\:flex), :is(:where(.peer) p ~ :is(:where(.peer):hover ~ .peer-\\[\\&\\:hover\\]\\:peer-\\[\\&_p\\]\\:flex)) { display: flex; } @media (hover: hover) { - .hover\\:peer-\\[\\&_p\\]\\:flex:hover:is(:where(.peer) p ~ *), .peer-\\[\\&_p\\]\\:hover\\:flex:is(:where(.peer) p ~ *):hover, .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover:is(:where(.peer) p ~ *):focus { + :is(:where(.peer) p ~ .hover\\:peer-\\[\\&_p\\]\\:flex:hover), :is(:where(.peer) p ~ .peer-\\[\\&_p\\]\\:hover\\:flex):hover, :is(:where(.peer) p ~ .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover):focus { display: flex; } } @@ -782,22 +795,22 @@ test('peer-*', async () => { ).toMatchInlineSnapshot(` " @media (hover: hover) { - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + :is(:where(.peer):hover ~ .peer-hover\\:flex) { display: flex; } } - .peer-focus\\:flex:is(:where(.peer):focus ~ *) { + :is(:where(.peer):focus ~ .peer-focus\\:flex) { display: flex; } @media (hover: hover) { - .peer-focus\\:peer-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.peer):hover ~ *), .peer-hover\\:peer-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer):focus ~ *) { + :is(:where(.peer):hover ~ :is(:where(.peer):focus ~ .peer-focus\\:peer-hover\\:flex)), :is(:where(.peer):focus ~ :is(:where(.peer):hover ~ .peer-hover\\:peer-focus\\:flex)) { display: flex; } } - .peer-hocus\\:flex:is(:is(:where(.peer):hover, :where(.peer):focus) ~ *) { + :is(:is(:where(.peer):hover, :where(.peer):focus) ~ .peer-hocus\\:flex) { display: flex; } " @@ -1897,7 +1910,7 @@ test('not', async () => { } } - .group-not-checked\\:flex:is(:where(.group):not(:checked) *), .group-not-hocus\\:flex:is(:where(.group):not(:hover, :focus) *), .group-not-hocus\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:hover, :focus) *), .group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *), .group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *), .peer-not-checked\\:flex:is(:where(.peer):not(:checked) ~ *), .peer-not-hocus\\:flex:is(:where(.peer):not(:hover, :focus) ~ *), .peer-not-hocus\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):not(:hover, :focus) ~ *), .peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *), .peer-not-\\[\\:checked\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):not(:checked) ~ *) { + :is(:where(.group):not(:checked) .group-not-checked\\:flex), :is(:where(.group):not(:hover, :focus) .group-not-hocus\\:flex), :is(:where(.group\\/parent-name):not(:hover, :focus) .group-not-hocus\\/parent-name\\:flex), :is(:where(.group):not(:checked) .group-not-\\[\\:checked\\]\\:flex), :is(:where(.group\\/parent-name):not(:checked) .group-not-\\[\\:checked\\]\\/parent-name\\:flex), :is(:where(.peer):not(:checked) ~ .peer-not-checked\\:flex), :is(:where(.peer):not(:hover, :focus) ~ .peer-not-hocus\\:flex), :is(:where(.peer\\/sibling-name):not(:hover, :focus) ~ .peer-not-hocus\\/sibling-name\\:flex), :is(:where(.peer):not(:checked) ~ .peer-not-\\[\\:checked\\]\\:flex), :is(:where(.peer\\/sibling-name):not(:checked) ~ .peer-not-\\[\\:checked\\]\\/sibling-name\\:flex) { display: flex; } " @@ -2126,7 +2139,7 @@ test('has', async () => { ), ).toMatchInlineSnapshot(` " - .group-has-checked\\:flex:is(:where(.group):has(:checked) *), .group-has-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *), .group-has-hocus\\:flex:is(:where(.group):has(:hover, :focus) *), .group-has-hocus\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:hover, :focus) *), .group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *), .group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *), .group-has-\\[\\&\\>img\\]\\:flex:is(:where(.group):has(* > img) *), .group-has-\\[\\&\\>img\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(* > img) *), .group-has-\\[\\+img\\]\\:flex:is(:where(.group):has( + img) *), .group-has-\\[\\>img\\]\\:flex:is(:where(.group):has( > img) *), .group-has-\\[\\>img\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has( > img) *), .group-has-\\[\\~img\\]\\:flex:is(:where(.group):has( ~ img) *), .peer-has-checked\\:flex:is(:where(.peer):has(:checked) ~ *), .peer-has-checked\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:checked) ~ *), .peer-has-hocus\\:flex:is(:where(.peer):has(:hover, :focus) ~ *), .peer-has-hocus\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:hover, :focus) ~ *), .peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *), .peer-has-\\[\\:checked\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:checked) ~ *), .peer-has-\\[\\&\\>img\\]\\:flex:is(:where(.peer):has(* > img) ~ *), .peer-has-\\[\\&\\>img\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(* > img) ~ *), .peer-has-\\[\\+img\\]\\:flex:is(:where(.peer):has( + img) ~ *), .peer-has-\\[\\>img\\]\\:flex:is(:where(.peer):has( > img) ~ *), .peer-has-\\[\\>img\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has( > img) ~ *), .peer-has-\\[\\~img\\]\\:flex:is(:where(.peer):has( ~ img) ~ *), .has-checked\\:flex:has(:checked), .has-hocus\\:flex:has(:hover, :focus), .has-\\[\\:checked\\]\\:flex:has(:checked), .has-\\[\\&\\>img\\]\\:flex:has(* > img), .has-\\[\\+img\\]\\:flex:has( + img), .has-\\[\\>img\\]\\:flex:has( > img), .has-\\[\\~img\\]\\:flex:has( ~ img) { + :is(:where(.group):has(:checked) .group-has-checked\\:flex), :is(:where(.group\\/parent-name):has(:checked) .group-has-checked\\/parent-name\\:flex), :is(:where(.group):has(:hover, :focus) .group-has-hocus\\:flex), :is(:where(.group\\/parent-name):has(:hover, :focus) .group-has-hocus\\/parent-name\\:flex), :is(:where(.group):has(:checked) .group-has-\\[\\:checked\\]\\:flex), :is(:where(.group\\/parent-name):has(:checked) .group-has-\\[\\:checked\\]\\/parent-name\\:flex), :is(:where(.group):has(* > img) .group-has-\\[\\&\\>img\\]\\:flex), :is(:where(.group\\/parent-name):has(* > img) .group-has-\\[\\&\\>img\\]\\/parent-name\\:flex), :is(:where(.group):has( + img) .group-has-\\[\\+img\\]\\:flex), :is(:where(.group):has( > img) .group-has-\\[\\>img\\]\\:flex), :is(:where(.group\\/parent-name):has( > img) .group-has-\\[\\>img\\]\\/parent-name\\:flex), :is(:where(.group):has( ~ img) .group-has-\\[\\~img\\]\\:flex), :is(:where(.peer):has(:checked) ~ .peer-has-checked\\:flex), :is(:where(.peer\\/sibling-name):has(:checked) ~ .peer-has-checked\\/sibling-name\\:flex), :is(:where(.peer):has(:hover, :focus) ~ .peer-has-hocus\\:flex), :is(:where(.peer\\/sibling-name):has(:hover, :focus) ~ .peer-has-hocus\\/sibling-name\\:flex), :is(:where(.peer):has(:checked) ~ .peer-has-\\[\\:checked\\]\\:flex), :is(:where(.peer\\/sibling-name):has(:checked) ~ .peer-has-\\[\\:checked\\]\\/sibling-name\\:flex), :is(:where(.peer):has(* > img) ~ .peer-has-\\[\\&\\>img\\]\\:flex), :is(:where(.peer\\/sibling-name):has(* > img) ~ .peer-has-\\[\\&\\>img\\]\\/sibling-name\\:flex), :is(:where(.peer):has( + img) ~ .peer-has-\\[\\+img\\]\\:flex), :is(:where(.peer):has( > img) ~ .peer-has-\\[\\>img\\]\\:flex), :is(:where(.peer\\/sibling-name):has( > img) ~ .peer-has-\\[\\>img\\]\\/sibling-name\\:flex), :is(:where(.peer):has( ~ img) ~ .peer-has-\\[\\~img\\]\\:flex), .has-checked\\:flex:has(:checked), .has-hocus\\:flex:has(:hover, :focus), .has-\\[\\:checked\\]\\:flex:has(:checked), .has-\\[\\&\\>img\\]\\:flex:has(* > img), .has-\\[\\+img\\]\\:flex:has( + img), .has-\\[\\>img\\]\\:flex:has( > img), .has-\\[\\~img\\]\\:flex:has( ~ img) { display: flex; } " @@ -2179,7 +2192,7 @@ test('aria', async () => { ]), ).toMatchInlineSnapshot(` " - .group-aria-checked\\:flex:is(:where(.group)[aria-checked="true"] *), .group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *), .group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *), .group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *), .group-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.group)[aria-valuenow="1"] *), .group-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-valuenow="1"] *), .peer-aria-checked\\:flex:is(:where(.peer)[aria-checked="true"] ~ *), .peer-aria-checked\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[aria-checked="true"] ~ *), .peer-aria-\\[modal\\]\\:flex:is(:where(.peer)[aria-modal] ~ *), .peer-aria-\\[modal\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[aria-modal] ~ *), .peer-aria-\\[valuenow\\=1\\]\\:flex:is(:where(.peer)[aria-valuenow="1"] ~ *), .peer-aria-\\[valuenow\\=1\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[aria-valuenow="1"] ~ *), .aria-checked\\:flex[aria-checked="true"], .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"], .aria-\\[valuenow_\\=_\\"1\\"\\]\\:flex[aria-valuenow="1"], .aria-\\[valuenow\\=1\\]\\:flex[aria-valuenow="1"] { + :is(:where(.group)[aria-checked="true"] .group-aria-checked\\:flex), :is(:where(.group\\/parent-name)[aria-checked="true"] .group-aria-checked\\/parent-name\\:flex), :is(:where(.group)[aria-modal] .group-aria-\\[modal\\]\\:flex), :is(:where(.group\\/parent-name)[aria-modal] .group-aria-\\[modal\\]\\/parent-name\\:flex), :is(:where(.group)[aria-valuenow="1"] .group-aria-\\[valuenow\\=1\\]\\:flex), :is(:where(.group\\/parent-name)[aria-valuenow="1"] .group-aria-\\[valuenow\\=1\\]\\/parent-name\\:flex), :is(:where(.peer)[aria-checked="true"] ~ .peer-aria-checked\\:flex), :is(:where(.peer\\/sibling-name)[aria-checked="true"] ~ .peer-aria-checked\\/sibling-name\\:flex), :is(:where(.peer)[aria-modal] ~ .peer-aria-\\[modal\\]\\:flex), :is(:where(.peer\\/sibling-name)[aria-modal] ~ .peer-aria-\\[modal\\]\\/sibling-name\\:flex), :is(:where(.peer)[aria-valuenow="1"] ~ .peer-aria-\\[valuenow\\=1\\]\\:flex), :is(:where(.peer\\/sibling-name)[aria-valuenow="1"] ~ .peer-aria-\\[valuenow\\=1\\]\\/sibling-name\\:flex), .aria-checked\\:flex[aria-checked="true"], .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"], .aria-\\[valuenow_\\=_\\"1\\"\\]\\:flex[aria-valuenow="1"], .aria-\\[valuenow\\=1\\]\\:flex[aria-valuenow="1"] { display: flex; } " @@ -2218,7 +2231,7 @@ test('data', async () => { ]), ).toMatchInlineSnapshot(` " - .group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *), .group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *), .group-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar" i] *), .group-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo$="bar baz" i] *), .group-data-\\[foo\\=1\\]\\:flex:is(:where(.group)[data-foo="1"] *), .group-data-\\[foo\\=1\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="1"] *), .group-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-foo="bar baz"] *), .peer-data-\\[disabled\\]\\:flex:is(:where(.peer)[data-disabled] ~ *), .peer-data-\\[disabled\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-disabled] ~ *), .peer-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo$="bar" i] ~ *), .peer-data-\\[foo\\$\\=bar_baz_i\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo$="bar baz" i] ~ *), .peer-data-\\[foo\\=1\\]\\:flex:is(:where(.peer)[data-foo="1"] ~ *), .peer-data-\\[foo\\=1\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo="1"] ~ *), .peer-data-\\[foo\\=bar\\ baz\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name)[data-foo="bar baz"] ~ *), .data-disabled\\:flex[data-disabled], .data-\\[foo\\$\\=\\'bar\\'_i\\]\\:flex[data-foo$="bar" i], .data-\\[foo\\$\\=bar_baz_i\\]\\:flex[data-foo$="bar baz" i], .data-\\[foo\\=1\\]\\:flex[data-foo="1"], .data-\\[foo\\=bar_baz\\]\\:flex[data-foo="bar baz"], .data-\\[potato_\\=_\\"salad\\"\\]\\:flex[data-potato="salad"], .data-\\[potato_\\^\\=_\\"salad\\"\\]\\:flex[data-potato^="salad"], .data-\\[potato\\=\\"\\^_\\=\\"\\]\\:flex[data-potato="^ ="], .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { + :is(:where(.group)[data-disabled] .group-data-\\[disabled\\]\\:flex), :is(:where(.group\\/parent-name)[data-disabled] .group-data-\\[disabled\\]\\/parent-name\\:flex), :is(:where(.group\\/parent-name)[data-foo$="bar" i] .group-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/parent-name\\:flex), :is(:where(.group\\/parent-name)[data-foo$="bar baz" i] .group-data-\\[foo\\$\\=bar_baz_i\\]\\/parent-name\\:flex), :is(:where(.group)[data-foo="1"] .group-data-\\[foo\\=1\\]\\:flex), :is(:where(.group\\/parent-name)[data-foo="1"] .group-data-\\[foo\\=1\\]\\/parent-name\\:flex), :is(:where(.group\\/parent-name)[data-foo="bar baz"] .group-data-\\[foo\\=bar\\ baz\\]\\/parent-name\\:flex), :is(:where(.peer)[data-disabled] ~ .peer-data-\\[disabled\\]\\:flex), :is(:where(.peer\\/sibling-name)[data-disabled] ~ .peer-data-\\[disabled\\]\\/sibling-name\\:flex), :is(:where(.peer\\/sibling-name)[data-foo$="bar" i] ~ .peer-data-\\[foo\\$\\=\\'bar\\'_i\\]\\/sibling-name\\:flex), :is(:where(.peer\\/sibling-name)[data-foo$="bar baz" i] ~ .peer-data-\\[foo\\$\\=bar_baz_i\\]\\/sibling-name\\:flex), :is(:where(.peer)[data-foo="1"] ~ .peer-data-\\[foo\\=1\\]\\:flex), :is(:where(.peer\\/sibling-name)[data-foo="1"] ~ .peer-data-\\[foo\\=1\\]\\/sibling-name\\:flex), :is(:where(.peer\\/sibling-name)[data-foo="bar baz"] ~ .peer-data-\\[foo\\=bar\\ baz\\]\\/sibling-name\\:flex), .data-disabled\\:flex[data-disabled], .data-\\[foo\\$\\=\\'bar\\'_i\\]\\:flex[data-foo$="bar" i], .data-\\[foo\\$\\=bar_baz_i\\]\\:flex[data-foo$="bar baz" i], .data-\\[foo\\=1\\]\\:flex[data-foo="1"], .data-\\[foo\\=bar_baz\\]\\:flex[data-foo="bar baz"], .data-\\[potato_\\=_\\"salad\\"\\]\\:flex[data-potato="salad"], .data-\\[potato_\\^\\=_\\"salad\\"\\]\\:flex[data-potato^="salad"], .data-\\[potato\\=\\"\\^_\\=\\"\\]\\:flex[data-potato="^ ="], .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { display: flex; } " @@ -2717,7 +2730,7 @@ test('variant order', async () => { } @media (hover: hover) { - .group-hover\\:flex:is(:where(.group):hover *), .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + :is(:where(.group):hover .group-hover\\:flex), :is(:where(.peer):hover ~ .peer-hover\\:flex) { display: flex; } } @@ -2990,7 +3003,7 @@ test('move modifier of compound variant to sub-variant if its also a compound va ]), ).toMatchInlineSnapshot(` " - .not-group-focus\\/name\\:flex:not(:is(:where(.group\\/name):focus *)), .group-peer-focus\\/name\\:flex:is(:where(.group\\/name):is(:where(.peer):focus ~ *) *), :where(:is(:where(.group\\/name):focus *)) .in-group-focus\\/name\\:flex, .has-group-focus\\/name\\:flex:has(:is(:where(.group\\/name):focus *)) { + .not-group-focus\\/name\\:flex:not(:is(:where(.group\\/name):focus *)), :is(:is(:where(.peer):focus ~ :where(.group\\/name)) .group-peer-focus\\/name\\:flex), :where(:is(:where(.group\\/name):focus *)) .in-group-focus\\/name\\:flex, .has-group-focus\\/name\\:flex:has(:is(:where(.group\\/name):focus *)) { display: flex; } " diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index c13c3ae12..d2695b4d2 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -610,7 +610,13 @@ export function createVariants(theme: Theme): Variants { selector = `:is(${selector})` } - node.selector = `&:is(${selector} *)` + // Put the target inside `:is` so browsers can limit style invalidation to + // it when the group changes. Keep `&` instead of the candidate's class so + // this also works with `@apply` and variants that change the target. + // + // The `:is` wrapper also keeps `has-group-*` from anchoring the group + // itself to the element carrying the utility. + node.selector = `:is(${selector} &)` // Track that the variant was actually applied didApply = true @@ -651,7 +657,7 @@ export function createVariants(theme: Theme): Variants { // For most variants we rely entirely on CSS nesting to build-up the final // selector, but there is no way to use CSS nesting to make `&` refer to - // just the `.group` class the way we'd need to for these variants, so we + // just the `.peer` class the way we'd need to for these variants, so we // need to replace it in the selector ourselves. let selector = node.selector.replaceAll('&', variantSelector) @@ -662,7 +668,8 @@ export function createVariants(theme: Theme): Variants { selector = `:is(${selector})` } - node.selector = `&:is(${selector} ~ *)` + // As with `group`, constrain the target without repeating its selector. + node.selector = `:is(${selector} ~ &)` // Track that the variant was actually applied didApply = true diff --git a/packages/tailwindcss/tests/ui.spec.ts b/packages/tailwindcss/tests/ui.spec.ts index 70ba7bd0f..e5da091d8 100644 --- a/packages/tailwindcss/tests/ui.spec.ts +++ b/packages/tailwindcss/tests/ui.spec.ts @@ -2387,6 +2387,120 @@ test('shadow DOM has access to variables', async ({ page }) => { } } +for (let kind of ['group', 'peer']) { + test(`${kind}-* variants`, async ({ page }) => { + let targets = html` +
+
+ ` + + let { getPropertyValue } = await render( + page, + kind === 'group' + ? html`
${targets}
` + : html`
+
+ ${targets} +
`, + css` + .owner > .applied { + @apply ${kind}-focus:z-20; + } + `, + ) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + expect(await getPropertyValue('#applied', 'z-index')).toEqual('auto') + + await page.locator('#trigger').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + expect(await getPropertyValue('#applied', 'z-index')).toEqual('20') + + await page.locator('#trigger').blur() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + expect(await getPropertyValue('#applied', 'z-index')).toEqual('auto') + }) +} + +test('group-* variants do not change specificity', async ({ page }) => { + let { getPropertyValue } = await render( + page, + html` +
+
+
+ `, + css` + @layer utilities { + .target { + z-index: 20; + } + .target.equal { + z-index: 30; + } + } + `, + ) + + await page.locator('#trigger').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + + await page.locator('#target').evaluate((element) => element.classList.add('equal')) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('30') +}) + +test('stacked group-* variants match regardless of the order of the groups', async ({ page }) => { + for (let [outer, inner] of [ + ['a', 'b'], + ['b', 'a'], + ]) { + let { getPropertyValue } = await render( + page, + html` +
+
+
+
+
+ `, + ) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + + await page.locator('#inner').evaluate((element) => element.removeAttribute('data-active')) + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + } +}) + +test('compound group-* and peer-* variants', async ({ page }) => { + let { getPropertyValue } = await render( + page, + html` +
+
+
+
+
+
+ `, + ) + + await page.locator('#peer').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('10') + expect(await getPropertyValue('#middle', 'z-index')).toEqual('auto') + + await page.locator('#outer').focus() + + expect(await getPropertyValue('#target', 'z-index')).toEqual('auto') + expect(await getPropertyValue('#middle', 'z-index')).toEqual('20') +}) + // --- const preflight = fs.readFileSync(path.resolve(__dirname, '..', 'preflight.css'), 'utf-8')