Compare commits

..

16 commits

Author SHA1 Message Date
Robin Malfait
fa81d697fe
Improve style invalidation performance of group-* and peer-* variants (#20513)
# 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) <noreply@anthropic.com>
2026-09-25 21:32:41 +02:00
Dex
9798a8ab5b
fix: correct two comment typos (#20507)
Fixes two typos in code comments (no behavior affected):

- `packages/@tailwindcss-cli/src/commands/build/index.ts`: `Succesfully`
-> `Successfully`
- `packages/tailwindcss/src/candidate.ts`: `preceeded` -> `preceded`

Co-authored-by: Dextheking1 <4175097+Dextheking1@users.noreply.github.com>
2026-09-25 11:28:27 +00:00
한국
722acdfebf
Don't treat \ as an escape inside CSS comments (#20508)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

The CSS parser treated `\` inside comments as an escape and skipped the
character after it. Because of that, a comment ending in `\*/` was never
closed, and the CSS that followed was swallowed or turned into a broken
rule:

```css
/* C:\temp\*/
.a { color: red }
```

Before this change the `.a` rule disappeared entirely, and `/* \*/ .a {
color: red }` produced the selector `* \*/ .a`. The same thing happened
to comments inside declaration values, where the comment ran on until
the next `*/` it could find, pulling following declarations into the
value.

Per [CSS Syntax Level 3
§4.3.2](https://www.w3.org/TR/css-syntax-3/#consume-comment), a comment
ends at the first `*/` and escapes aren't processed inside comments.
This removes the backslash handling from both comment-scanning loops in
`css-parser.ts` (top-level and inside declaration values).

The existing test `/*Hello, \*\/ world!*/` keeps passing, since that
input contains no `*/` before the final one.

## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

Added two tests to `packages/tailwindcss/src/css-parser.test.ts` (both
run with Unix and Windows line endings):

- a top-level comment ending in `\*/` followed by a rule
- a comment ending in `\*/` inside a custom property value, followed by
another declaration

Both fail without the change to `css-parser.ts` and pass with it.

```sh
pnpm vitest run packages/tailwindcss/src/css-parser.test.ts
pnpm vitest run --project tailwindcss
pnpm run lint
```

---------

Co-authored-by: Robin Malfait <robin.malfait@shopify.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-09-25 11:16:58 +00:00
Alexandre Kohler
86f3daf912
Sort breakpoint variants with decimal values numerically (#20512)
## Summary

`compareBreakpoints` compared values in the same unit with `parseInt`,
which drops the fractional part, so `40.25rem` and `40.5rem` were
treated as equal. As a result `min-[40.5rem]:*` could be emitted before
`min-[40.25rem]:*` (the same happened for `@min-*` container queries),
which means the smaller breakpoint wins in the cascade. This switches
the comparison to `parseFloat` so decimal values are ordered
numerically; values that were already sorted correctly are unaffected.

## Test plan

- Added a test to `packages/tailwindcss/src/variants.test.ts` that sorts
`min-[40.25rem]`, `min-[40.5rem]`, `max-[40.25rem]` and `max-[40.5rem]`.
It fails without the change (the `min-[40.5rem]` rule is emitted before
`min-[40.25rem]`) and passes with it.
- `vitest run src/variants.test.ts -t "decimal values"` (in
`packages/tailwindcss`)
- `vitest run` (in `packages/tailwindcss`): 42 files, 5009 tests passed
- `prettier --check` on the changed files
2026-09-25 11:07:37 +00:00
cui fliter
41d9cae8e5
Reject candidates with multiple modifiers (#20466)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

`segment()` preserves empty top-level segments, but the candidate and
variant parsers currently use the truthiness of the third segment to
detect additional modifiers.

As a result, inputs such as `bg-red-500/50/`, `bg-red-500/50//foo`,
`group-hover/foo/:flex`, and `group-hover/foo//bar:flex` can be parsed
as valid candidates even though they contain multiple slash modifier
segments.

This change checks the number of segments instead of the value of the
third segment. Single modifiers such as `bg-red-500/50` and
`group-hover/foo:flex` continue to parse normally, while all additional
top-level `/` segments are rejected.


## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

- `pnpm exec vitest run packages/tailwindcss/src/candidate.test.ts
--hideSkippedTests`
- `pnpm exec vitest run packages/tailwindcss/src --hideSkippedTests`
- `pnpm exec prettier --check packages/tailwindcss/src/candidate.ts
packages/tailwindcss/src/candidate.test.ts`

`pnpm --filter=tailwindcss lint` was also attempted, but currently fails
on existing cross-package dependency and Bun type errors unrelated to
this change.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-09-08 13:46:59 +00:00
Wei
f723e834ad
Don't warn about Angular's ::ng-deep and :host-context() when optimizing CSS (#20434)
Fixes #20433.

The warning filter in `optimize.ts` already ignores `:deep()`,
`:slotted()` and `:global()`. Angular's two deep selectors are the same
kind of thing — non-standard pseudo-selectors that the framework's
compiler resolves before the CSS reaches a browser — but they aren't
covered, so every Angular component stylesheet using them prints a
warning block per occurrence.

```
Found 2 warnings while optimizing generated CSS:

Issue #1:
│ :host ::ng-deep .some-child, :host
┆        ^-- 'ng-deep' is not recognized as a valid pseudo-element. Did you mean ':ng-deep' (pseudo-class) or is this a typo?
```

Angular strips both during view-encapsulation shimming — `::ng-deep` via
`_shadowDeepSelectors = /(?:>>>)|(?:\/deep\/)|(?:::ng-deep)/g`, and
`:host-context()` in the same pass — so neither ever reaches a browser.

Worth noting that `/deep/` and `>>>`, Angular's two other spellings of
the deep selector, already pass silently because
`nonStandard.deepSelectorCombinator` is enabled. `::ng-deep` is the only
one that warns, and it's the spelling the Angular docs use — so in
practice every Angular codebase hits this. On the workspace where I ran
into it (7 Angular apps), a production build printed 425 `ng-deep`
warnings and 2 `host-context` ones.

## Test plan

There's no automated coverage here because the warning path is behind
`process.env.NODE_ENV !== 'test'`, so a spy sees nothing under Vitest
regardless of the filter — the same reason #20277 shipped without one.
Instead I ran the file before and after the change against Lightning CSS
1.33.0 directly, counting emitted warning blocks:

| Input | Before | After |
| --- | --- | --- |
| `:host ::ng-deep .a, :host ::ng-deep .b { … }` | 1 | 0 |
| `:host-context(.dark) .a { … }` | 1 | 0 |
| `:deep(.a) { … }` | 0 | 0 |
| `.a::totally-not-real { … }` — genuine typo | 1 | **1** |

Generated CSS is byte-identical before and after; only the warning is
suppressed. Genuine unknown pseudo-selectors still warn, so the typo
hint the message exists for is preserved.

Happy to restructure this if you'd prefer the predicate extracted so it
can be unit-tested, or to split `ng-deep` and `host-context` into their
own block rather than extending the existing regex.

A minimal reproduction of the original issue is at
https://gist.github.com/weilinzung/ace42ceb95c2f47b6747cc1f34f162bd.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-31 10:14:54 +00:00
Simon Bobrov
9f451eead8
Cold start performance improvements (#20427)
<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

Hi guys, thanks for you great work!

I work on performance improvements on
https://github.com/schoero/eslint-plugin-better-tailwindcss project.
Part of issues can be fixed on the tailwind side only.

This is a first fix, I have a bigger one in mind, it would require a
small additional public API method - out of scope of this PR.

Please let me know what you think.

## Finding

`getVariantOrder()` re-sorts all parsed variants with the (expensive)
variant comparator on every call, and it is called by every
compileCandidates() invocation — per build pass, per @apply
substitution, and hundreds of times during candidate canonicalization
via the variant signature caches. Since parsedVariants is append-only,
the computed order only changes when a new variant is parsed, so we can
cache the result and invalidate on parsedVariants.size. This makes cold
canonicalization (IntelliSense, lint plugins) faster and removes
repeated sorting from @apply substitution and incremental rebuilds.

## Test plan

Tested at https://github.com/smnbbrv/better-tailwindcss-bench . The
relevant part is the `epbt-now / tw-patched`

**plugin cost** (ms, cold run net of parse baseline)

| codebase | epbt-now / tw-now | epbt-patched / tw-now | epbt-now /
tw-patched | epbt-patched / tw-patched |
| ---------- | ----------------: | --------------------: |
--------------------: | ------------------------: |
| mixed | 6292 | 3965 (-37%) | 5395 (-14%) | 3595 (-43%) |
| repetitive | 5279 | 2425 (-54%) | 4819 (-9%) | 2124 (-60%) |
| unique | 6096 | 5458 (-10%) | 5507 (-10%) | 5295 (-13%) |
2026-08-31 10:05:03 +00:00
Srinu desetti
90f8ff41c8
Don't space out and/or/not inside function calls in supports-[…] variants (#20420)
## Summary

The `supports-[…]` variant works around a Chrome bug where `@supports
(a)or(b)`
is invalid by spacing out the `and`, `or`, and `not` keywords. However,
the
replacement is applied to the entire value, including the inside of
function
calls, where these words can be part of a selector.

For example, `supports-[selector(a:not(.foo))]:flex` generates:

```css
@supports selector(a: not (.foo))
```

The selector `a: not (.foo)` is unparsable, so a condition that is true
in
every browser silently becomes false and the utility never applies. The
same
happens to class names like `.and` or `.or` inside `selector(…)`.

This PR only spaces out the keywords at the condition level: parens
preceded by
an identifier (other than the keywords themselves) start a function
call, and
everything inside is left as-is. The Chrome workaround still applies to
the
condition itself, e.g. `supports-[(display:grid)or(display:flex)]` still
becomes `@supports (display: grid) or (display: flex)`.

## Test plan

- Added a test covering `selector(a:not(.foo))`, class names
`.and`/`.or`
inside `selector(…)`, the Chrome `(a)or(b)` workaround, and a top-level
  `not(…)` condition.
- `pnpm vitest run packages/tailwindcss/src/variants.test.ts` — 102
passed.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-14 19:43:33 +00:00
Srinu desetti
7a7f386648
Don't emit utilities that resolve a theme value when an unsupported modifier is used (#20419)
## Summary

Modifiers on functional utilities that resolve a named theme value are
silently
dropped instead of invalidating the candidate. For example, with the
default theme:

- `rounded-sm/[5]` emits the same CSS as `rounded-sm` (the `[5]` is
ignored)
- `shadow-sm/foo`, `inset-shadow-sm/foo`, `text-shadow/foo`, and
`drop-shadow/foo`
  emit the full shadow CSS with the invalid `foo` modifier ignored

This is inconsistent with how every sibling code path behaves:
`rounded/foo`,
`rounded-[4px]/foo`, `rounded-sm/5`, and `drop-shadow-xl/foo` all
correctly
produce no output, because those paths check `candidate.modifier`.

This PR adds the missing guards:

- In the generic `functionalUtility` handler, a candidate whose named
value
resolves from the theme now rejects modifiers (except fractions like
`w-1/2`,
  where the modifier is part of the resolved value).
- The `shadow`, `inset-shadow`, and `text-shadow` utilities now apply
the same
`if (candidate.modifier && !alpha) return` guard in their default-value,
arbitrary-value, and named-size branches that `drop-shadow` already
applies,
  and `drop-shadow` gets it in its default-value branch too.

Existing tests asserting candidates like `drop-shadow/foo` produce no
output were
passing for the wrong reason: they run without a theme, so the theme
lookup fails
before the modifier is ever considered. The new tests provide a theme so
the
invalid modifier is what invalidates the candidate.

## Test plan

- Added assertions to the `rounded`, `filter`, `shadow`, `inset-shadow`,
and
`text-shadow` tests that compile candidates with invalid modifiers
against a
theme that defines the relevant values, and expect no output. All of
them fail
  without the fix.
- `pnpm vitest run packages/tailwindcss/src/utilities.test.ts` — 398
passed.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-14 16:47:34 +00:00
Robin Malfait
021b7fe6f2
Canonicalization: prevent inlining CSS-wide keywords (#20417)
This PR fixes an issue where canonicalization suggestions in
intellisense result in 'weird' suggestions.

```
The class text-foreground/60 can be written as text-default-soft-hover
```

If we look at the CSS provided by the issue, this doesn't immediately
make sense:
```css
@theme {
  --color-foreground: var(--foreground);
  --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
}

:root {
  --foreground: oklch(0.2103 0.0059 285.89); /* near-black */
  --default: oklch(94% 0.001 286.375); /* light gray */
}
```

But it turns out that when you use Uniwind (React Native) with HeroUI,
that the setup looks more like this:
```css
@import 'tailwindcss';

@theme {
  --foreground: unset;
  --default: unset;
}

@theme inline {
  --color-foreground: var(--foreground);
  --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
}
```

During the canonicalization step, we inline all the `@theme` values, the
reason for this is that `text-[#fff]` can be turned into `text-white`
even though they look slightly different:
```css
.text-\[\#fff\] {
  color: #fff;
}
.text-white {
  color: var(--color-white, #fff);
}
```

But when we inline them, it looks like this:
```css
.text-\[\#fff\] {
  color: #fff;
}
.text-white {
  color: #fff;
}
```

Internally, we use signatures to make sure that they are safe to be
subtituted with eachother. In this case, the signatures will look like
this:
```css
.x {
  color: #fff;
}
.x {
  color: #fff;
}
```

If we now look at the signatures of the original issue, you would see:
```css
/* text-foreground/60 */
.x {
  color: color-mix(in_oklab,unset_60%,transparent);
}

/* text-default-soft-hover */
.x {
  color: color-mix(in_oklab,unset_60%,transparent);
}
```

That's because the `@theme` variables were inlined, resulting in the
exact same signature, thus we consider them the same.

This PR makes sure to never inline [CSS-wide
keywords](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords)
(such as `unset`) and therefore keeping the CSS variable reference:
```css
/* text-foreground/60 */
.x {
  color: color-mix(in_oklab,var(--foreground)_60%,transparent);
}

/* text-default-soft-hover */
.x {
  color: color-mix(in_oklab,var(--default)_60%,transparent);
}
```

Fixes:
https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610

## Test plan

1. Existing tests pass
2. Added a regression test

Checked manually in the regression repo

Before:
<img width="1358" height="421" alt="image"
src="https://github.com/user-attachments/assets/8ab3de97-17e4-4e11-b335-bb37218ff2ee"
/>

After:
<img width="1177" height="247" alt="image"
src="https://github.com/user-attachments/assets/cbbfcf04-3ed3-4fa7-a3eb-ef607239f4ad"
/>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-14 12:16:00 +02:00
Robin Malfait
f7f58f0806
Move debug logs in .tailwindcss folder (#20416)
Instead of writing debug logs (triggered by using `DEBUG=*`) in the root
of the project as `tailwindcss-<pid>.log`, we will write them to
`.tailwindcss/logs/scanner-<timestamp>-<pid>.log` instead.

The reasoning for this is that we won't pollute the root of the project.
Another reason is that if you don't ignore `.log` files via
`.gitignore`, it could be that you end up in a loop if you use `DEBUG=*
vite` because a new `.log` file might trigger a new build. We noticed
this while looking at
https://github.com/tailwindlabs/tailwindcss/discussions/20382.

The `.tailwindcss` folder will come with a `.gitignore` file that
ignores everything (`*`).

## Test plan

1. Nothing really changed here, all tests should pass


Testing this in a local project, it looks like this:
<img width="483" height="165"
alt="file-35bfc43606c556f2380a161c7595d103"
src="https://github.com/user-attachments/assets/629519c8-8f2b-46ee-8046-9ba3749f319a"
/>
2026-08-13 23:16:30 +02:00
Robin Malfait
de9e71c599
fix referenced link 2026-08-13 21:31:51 +02:00
Robin Malfait
00ef99df3d
Do not force full page reloads when using @tailwindcss/vite (#20414)
This PR removes all of the custom HMR handling we had in the
`@tailwindcss/vite` plugin.

When Vite 7.1 was introduced, Vite stopped performing a full page reload
for unknown files and instead started performing normal `hmr` updates.
This resulted in this issue:
https://github.com/tailwindlabs/tailwindcss/issues/19637

At the time, it felt like something we could easily re-add: if a file is
not covered by Vite, we can perform a `full-reload`. This meant that a
`.php` file would trigger a full page reload as expected.

The reason the `.php` file triggered Vite in the first place is because
those files were scanned by us (`@tailwindcss/vite`) so it made sense.

However, this then resulted in a plethora of issues, and it feels a bit
like a game of whac-a-mole.

- https://github.com/tailwindlabs/tailwindcss/issues/19744
- https://github.com/tailwindlabs/tailwindcss/issues/19903
- https://github.com/tailwindlabs/tailwindcss/issues/20320
- https://github.com/tailwindlabs/tailwindcss/issues/20378
- https://github.com/tailwindlabs/tailwindcss/issues/20411

Fixes: #19744
Fixes: #19903
Fixes: #20320
Fixes: #20378
Fixes: #20411

We kept updating the logic by safelisting certain extensions, checking
different servers and/or environments, handling the fact that `server`
in the callback could be absent in `experimental.bundledDev` mode, etc.
etc.

Now, when investigating the last issue
(https://github.com/tailwindlabs/tailwindcss/issues/20411), I can
trigger full reloads by changing `.json`, `.yaml` or `.svg` files. This
makes sense since they aren't handled by default.

So thinking about this more, I think it's just not Tailwind's
responsibility to tell Vite to reload the browser or not. Yes, we use
the `addWatchFile` API, so files are being watched because of us.
However, our only goal is to update the `.css` file (and HMR that).

This means that we can just drop all the custom HMR handling we have in
`@tailwindcss/vite`.

This also means that
https://github.com/tailwindlabs/tailwindcss/issues/19637 would regress
and won't trigger full page reloads. But this can be easily handled by a
plugin responsible for this behavior:

- https://github.com/ElMassimo/vite-plugin-full-reload

## Test plan

1. All tests pass
2. Manually tested and changing unknown files don't result in a full
page reload
2026-08-13 17:00:08 +02:00
Matt Van Horn
b9286a7346
fix: Drop invalid UTF-8 scanner candidates (#20389)
## Summary

Replace the three unchecked scanner conversions in
`crates/oxide/src/scanner/mod.rs` with checked conversions that omit
only extracted slices that are not valid UTF-8. Apply the check in the
shared `extract` pipeline so initial scans, incremental `scan_content`
calls, and CSS-variable extraction cannot insert invalid strings into
scanner state, and apply the same policy to both branches of
`get_candidates_with_positions` while preserving byte offsets and the
legacy `-[]` restoration. Keep the extractor's byte-oriented CSS
identifier classification unchanged: accepting non-ASCII bytes during
extraction is useful for valid multibyte code points, while the
conversion boundary is the authoritative place to enforce the `String`
contract.

The scanner currently converts extracted byte slices with unchecked
UTF-8 constructors at the shared extraction boundary and both
candidate-with-position branches. A source file containing a stray
continuation byte can therefore produce an invalid `String`, violating
Rust's string invariant and allowing corrupted candidates to persist in
a long-lived scanner. The thread provides a deterministic reproduction
using invalid bytes inside an arbitrary value, so the problem no longer
depends on reproducing the originally reported Turbopack race. Valid
candidates found alongside malformed byte sequences must continue to be
returned normally.

Fixes #20368

## Test plan

Not applicable to this change.

AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
2026-08-13 13:01:13 +02:00
Robin Malfait
84bea42b6c
Visualize scanner related tests (#20412)
This PR improves the scanner related tests a bit. Right now all the
scanner tests (a lot of them) are somewhat hard to reason about and to
figure out whether these are actually correct or not.

With this PR, the existing are extended with a visualization of the file
system tree, but with indicators to know whether or not a file was
scanned or not. If a `.gitignore` is present, it's content is also shown
to make it easier to see why some files are included or not included.

One of the outputs look like this:
```
.
├── ✗ .gitignore
│       src/ignored-by-gitignore.html
└── ✓ src
    ├── ✗ ignore-by-extension.bin
    ├── ✓ ignored-by-gitignore.html
    ├── ✗ ignored-by-source-not.html
    ├── ✓ keep-me.html
    └── ✓ new-file.html
```

I'm sure we can cleanup a lot of these tests, simplify, deduplicate, but
I didn't want to do that as part of this PR. This PR is purely adding
this visualization.

This uses the [insta.rs](https://insta.rs) library for (inline) snapshot
tests. If at some point the snapshots change, then you get a nice UI:
<img width="958" height="670" alt="image"
src="https://github.com/user-attachments/assets/652cf48f-e1b1-4fe5-a42a-29c5a888063a"
/>


## Test plan

1. All tests should pass [ci-all]
2. Nothing in the code itself changed, so nothing should break

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-13 12:45:40 +02:00
Robin Malfait
606438c6c3
rebase ignore crate from 0.4.24 to 0.4.33 (#20409)
This PR updates the vendored `ignore/` crate to make use of the upstream
improvements.

We can't fully use the native `ignore` crate yet because of the missing
functionality that we added ourselves. Our own changes are still marked
as `CHANGED:`

## Test plan

1. All tests should still pass [ci-all]

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-08-13 12:45:40 +02:00
30 changed files with 1706 additions and 2417 deletions

View file

@ -23,9 +23,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383))
- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w<flex>`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387))
- Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392))
- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/discussions/20397))
- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/pull/20397))
- Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399))
- Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408))
- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389))
- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414))
- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417))
- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419))
- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420))
- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434))
- 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

View file

@ -48,7 +48,7 @@ static IGNORED_EXTENSIONS_GLOB: sync::LazyLock<String> = sync::LazyLock::new(||
)
});
static BINARY_EXTENSIONS_GLOB: sync::LazyLock<String> = sync::LazyLock::new(|| {
pub static BINARY_EXTENSIONS_GLOB: sync::LazyLock<String> = sync::LazyLock::new(|| {
format!(
"*.{{{}}}",
include_str!("fixtures/binary-extensions.txt")

View file

@ -2,6 +2,5 @@ package-lock.json
pnpm-lock.yaml
bun.lockb
.gitignore
.ignore
.env
.env.*

View file

@ -37,20 +37,52 @@ pub fn init_tracing() {
return;
}
let file_path = format!("tailwindcss-{}.log", std::process::id());
let file = OpenOptions::new()
let root = Path::new(".tailwindcss");
let logs_dir = root.join("logs");
if let Err(err) = std::fs::create_dir_all(&logs_dir) {
eprintln!(
"{} Failed to create {}, skipping debug logs ({err})",
dim("[DEBUG]"),
highlight(&logs_dir.display().to_string())
);
return;
}
// Ensure everything inside `.tailwindcss/` is ignored by git. The file is only created if it
// doesn't exist yet, an existing `.gitignore` is left untouched.
if let Ok(mut file) = std::fs::File::create_new(root.join(".gitignore")) {
_ = file.write_all(b"*\n");
}
let file_path = logs_dir.join(format!(
"scanner-{}-{}.log",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
std::process::id()
));
let file = match OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
.unwrap_or_else(|_| panic!("Failed to open {file_path}"));
{
Ok(file) => file,
Err(err) => {
eprintln!(
"{} Failed to create {}, skipping debug logs ({err})",
dim("[DEBUG]"),
highlight(&file_path.display().to_string())
);
return;
}
};
let file_path = Path::new(&file_path);
let absolute_file_path = dunce::canonicalize(file_path)
.unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}"));
let absolute_file_path = dunce::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone());
eprintln!(
"{} Writing debug info to: {}\n",
dim("[DEBUG]"),
highlight(absolute_file_path.as_path().to_str().unwrap())
highlight(&absolute_file_path.display().to_string())
);
let file = Arc::new(Mutex::new(file));

View file

@ -10,13 +10,11 @@ use crate::scanner::sources::{
public_source_entries_to_private_source_entries, PublicSourceEntry, SourceEntry, Sources,
};
use crate::GlobEntry;
use auto_source_detection::BINARY_EXTENSIONS_GLOB;
use bstr::ByteSlice;
use fast_glob::glob_match;
use fxhash::{FxHashMap, FxHashSet};
use ignore::{
gitignore::{Gitignore, GitignoreBuilder},
WalkBuilder,
};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use init_tracing::{init_tracing, SHOULD_TRACE};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
@ -24,71 +22,16 @@ use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tracing::event;
// # `@source` semantics
// @source "some/folder"; // This is auto source detection
// @source "some/folder/**/*"; // This is auto source detection
// @source "some/folder/*.html"; // This is just a glob, but new files matching this should be included
// @source "node_modules/my-ui-lib"; // Auto source detection but since node_modules is explicit we allow it
// // Maybe could be considered `external(…)` automatically if:
// // 1. It's git ignored but listed explicitly
// // 2. It exists outside of the current working directory (do we know that?)
//
// Every `@source` directive is classified as one of:
//
// - `Auto`: `@source "some/folder"` or `@source "some/folder/**/*"` — auto source detection.
// The folder is scanned recursively while respecting `.gitignore` files and the default rules
// (skip `node_modules`/`.git`/…, skip binary and irrelevant extensions, skip lock files, …).
//
// - `External`: an `Auto` source whose folder is itself ignored (by a `.gitignore` or because
// it's a default-ignored directory like `node_modules`), e.g.
// `@source "node_modules/my-ui-lib"`. Since the folder was listed explicitly, its ignoredness
// is bypassed: everything inside is scanned as if it were an `Auto` source, except that
// `.gitignore` files from at or above the folder no longer apply — they (including the
// self-ignoring `*` file that generators typically place inside such folders) are what made
// it ignored in the first place. `.gitignore` files *deeper inside* the folder still apply,
// and so do the default rules: nested `node_modules`, binary extensions, etc. stay ignored.
//
// - `Pattern`: `@source "some/folder/*.html"` — an explicit glob. Only files matching the glob
// are scanned. The *static* prefix of the glob (`some/folder`) is the explicit part: it is
// reached even when it is git ignored or hidden behind a default-ignored directory
// (`@source "node_modules/lib/dist/*.html"` works). The *wildcard* part is not explicit:
// while expanding it we still respect `.gitignore` files inside the walked subtree and the
// default-ignored directories (`@source "./**/*.html"` does not descend into `node_modules`
// or a git ignored `dist/`; `@source "./dist/**/*.html"` does descend into `dist/`).
// Individual *files* matching the glob are always included, even when git ignored — you were
// explicit about wanting files of that shape (`@source "git-ignored.html"` and
// `@source "*.styl"` work). Files that are ignored by default are only included when the
// pattern is explicit about them: it names a concrete file (`@source "do-include-me.bin"`,
// `@source ".env"`) or pins an extension (`@source "logo.{jpg,png}"`). A glob that does
// neither (e.g. `@source "blog/*/post/**/*"`) still applies the default file rules.
//
// - `Ignored`: `@source not "…"` — excludes matching files/folders, even when a `.gitignore`
// or another `@source` allows them.
//
// Later directives win over earlier ones on conflict: `@source not "./x"` followed by
// `@source "./x/keep.html"` scans `keep.html`, and vice versa excludes it. The same holds for
// directory sources: `@source not "./x"` followed by `@source "./x"` re-includes `./x` (with
// the normal auto source detection rules applied inside).
//
// # Implementation
//
// All sources are scanned in a single file system walk. The vendored `ignore` crate only
// provides the traversal itself (parallel walking, symlink loop handling); its built-in
// gitignore handling is disabled, because it computes one global verdict per path while the
// `@source` semantics are per source: the same directory can be pruned for an auto source but
// walkable for a pattern source, and a file can be gitignored for an auto source but rescued
// by a glob.
//
// Instead, the [`Resolver`] implements the semantics in the walker's `filter_entry` callback,
// backed by its own lazily-loaded cache of the on-disk ignore files (`.gitignore`, `.ignore`,
// and the repository's `.git/info/exclude`, applied up to the git repository root):
//
// - The walk roots are the "maximal" source bases; nested bases are reached by walking, and
// the resolver keeps the static path towards an explicitly listed base open even through
// ignored directories.
//
// - A directory is entered when at least one source can contribute files inside of it, where
// each source kind applies its own rules: auto sources check the default rules and the full
// gitignore chain, external sources only the default rules, and pattern sources check the
// default rules, the `.gitignore` files at or below their base, and whether the glob can
// match anything inside the directory. Directories that cannot contribute anything are
// never descended into.
//
// - A file is kept when at least one source includes it, honoring the directive order of
// `@source not` (the later directive wins).
// @source "do-include-me.bin"; // `.bin` is typically ignored, but now it's explicit so should be included
// @source "git-ignored.html"; // A git ignored file that is listed explicitly, should be scanned
#[derive(Debug, Clone)]
pub enum ChangedContent {
@ -120,9 +63,6 @@ pub struct Scanner {
/// The walker to detect all files that we have to scan
walker: Option<WalkBuilder>,
/// The resolver implementing the `@source` semantics for the walker
resolver: Option<Arc<Resolver>>,
/// All found extensions
extensions: FxHashSet<String>,
@ -171,13 +111,11 @@ impl Scanner {
}
}
let resolver = Arc::new(Resolver::new(&sources));
let walker = create_walker(resolver.clone());
let walker = create_walker(&sources);
Self {
sources,
walker,
resolver: Some(resolver),
..Default::default()
}
}
@ -418,15 +356,14 @@ impl Scanner {
let i = s.as_ptr() as usize - offset;
let original = &original_content[i..i + s.len()];
if original.contains_str("-[]") {
return Some(unsafe {
(String::from_utf8_unchecked(original.to_vec()), i)
});
return String::from_utf8(original.to_vec())
.ok()
.map(|candidate| (candidate, i));
}
// SAFETY: When we parsed the candidates, we already guaranteed that the byte
// slices are valid, therefore we don't have to re-check here when we want to
// convert it back to a string.
Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) })
String::from_utf8(s.to_vec())
.ok()
.map(|candidate| (candidate, i))
}
_ => None,
@ -470,16 +407,7 @@ impl Scanner {
for entry in all_entries {
match entry {
WalkEntry::Dir(path) => {
// Directories that are only walked to reach an explicitly listed base are
// not part of any source's content: they must not widen the generated
// file watcher globs.
let contributes = self
.resolver
.as_ref()
.is_some_and(|resolver| resolver.contributes_dir(&path));
if contributes {
self.dirs.insert(path);
}
self.dirs.insert(path);
}
WalkEntry::File {
path,
@ -688,7 +616,7 @@ where
a
})
.into_iter()
.map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) })
.filter_map(|s| String::from_utf8(s.to_vec()).ok())
.collect()
}
@ -778,637 +706,203 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
Arc::try_unwrap(collected).unwrap().into_inner().unwrap()
}
/// Sets up the single walker for all sources.
/// Sets up a WalkBuilder with all source roots, gitignore rules, and source pattern matching.
///
/// The walker is only used for the (parallel, symlink aware) traversal itself: all ignore
/// semantics are implemented by the [`Resolver`] in the `filter_entry` callback. The crate's
/// built-in gitignore handling can't be used because it computes one global verdict per path,
/// while the `@source` semantics are per source (see the spec at the top of this file): the
/// same directory can be pruned for an auto source but walkable for a pattern source, and a
/// file can be gitignored for an auto source but rescued by a glob.
///
/// The walk roots are the "maximal" source bases: a base contained in another base is reached
/// by walking, which the resolver allows even through ignored directories (the static path to
/// an explicit base bypasses ignore rules).
fn create_walker(resolver: Arc<Resolver>) -> Option<WalkBuilder> {
let mut roots = resolver.walk_roots().into_iter();
let first_root = roots.next()?;
/// This is the common setup shared between the full walker (with mtime tracking for re-scans)
/// and the parallel walker (without mtime tracking for the initial scan).
fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default();
let mut first_root: Option<&PathBuf> = None;
let mut builder = WalkBuilder::new(first_root);
for root in roots {
builder.add(root);
let mut ignores: Vec<(&PathBuf, Vec<String>)> = Default::default();
let mut emit = |base, pattern| match ignores.last_mut() {
Some((prev_base, patterns)) if *prev_base == base => {
patterns.push(pattern);
}
_ => {
ignores.push((base, vec![pattern]));
}
};
for source in sources.iter() {
match source {
SourceEntry::Auto { base } => {
if first_root.is_none() {
first_root = Some(base);
} else {
other_roots.insert(base);
}
}
SourceEntry::Pattern { base, pattern } => {
let pattern = pattern.to_owned();
if first_root.is_none() {
first_root = Some(base);
} else {
other_roots.insert(base);
}
if !pattern.contains("**") {
// Specific patterns should take precedence even over git-ignored files:
emit(base, format!("!{}", pattern));
} else {
// Assumption: the pattern we receive will already be brace expanded. So
// `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`.
if let Some(extension) = Path::new(&pattern).extension() {
// Extend auto source detection to include the extension
emit(base, format!("!*.{}", extension.to_string_lossy()));
}
}
}
SourceEntry::Ignored { base, pattern } => {
emit(base, pattern.to_owned());
}
SourceEntry::External { base } => {
if first_root.is_none() {
first_root = Some(base);
} else {
other_roots.insert(base);
}
// External sources should take precedence even over git-ignored files:
emit(base, "!/**/*".to_owned());
// External sources should still disallow binary extensions:
emit(base, BINARY_EXTENSIONS_GLOB.clone());
}
}
}
let mut builder = WalkBuilder::new(first_root?);
// We have to follow symlinks
builder.follow_links(true);
// Disable all of the built-in filtering (hidden files, .gitignore files, parent
// directories, global gitignore files, …): the resolver implements the ignore semantics.
builder.standard_filters(false);
// Scan hidden files / directories
builder.hidden(false);
builder.filter_entry(move |entry| resolver.keep(entry));
// Don't respect global gitignore files
builder.git_global(false);
Some(builder)
}
// By default, allow .gitignore files to be used regardless of whether or not
// a .git directory is present. This is an optimization for when projects
// are first created and may not be in a git repo yet.
builder.require_git(false);
/// Implements the `@source` semantics for a single file system walk.
///
/// For every walked entry, the resolver computes the union of the per-source verdicts:
///
/// - a directory is entered when at least one source can contribute files inside of it
/// - a file is kept when at least one source includes it, honoring the directive order of
/// `@source not` (the later directive wins)
///
/// The per-source verdicts require gitignore decisions relative to different anchors (see
/// [`Boundary`]: each source kind respects a different part of the `.gitignore` chain), so the
/// resolver maintains its own lazily-loaded cache of ignore files instead of using the
/// walker's built-in handling.
#[derive(Debug)]
struct Resolver {
/// `Auto` sources: directive position and base
autos: Vec<(usize, PathBuf)>,
/// `External` sources: directive position and base
externals: Vec<(usize, PathBuf)>,
/// `Pattern` sources, grouped by base
patterns: Vec<PatternGroup>,
/// `@source not` directives
nots: Vec<NotRule>,
/// Lazily loaded ignore files (`.gitignore`, `.ignore`, `.git/info/exclude`) per directory
ignore_files: IgnoreFiles,
/// Memoized "is this directory reachable for an auto source": every directory on the path
/// from an auto base down to it passes the gitignore chain and the default rules
auto_reachable: Mutex<FxHashMap<PathBuf, bool>>,
/// Memoized "is this directory reachable for an external source": like `auto_reachable`,
/// but only `.gitignore` files strictly below the external base apply
external_reachable: Mutex<FxHashMap<PathBuf, bool>>,
}
/// All `Pattern` sources sharing a base, e.g. `@source "src/*.{html,jsx}"` produces the
/// patterns `/*.html` and `/*.jsx` for the base `src`. Patterns carry the position of their
/// `@source` directive to resolve conflicts with `@source not` directives: the later
/// directive wins.
#[derive(Debug)]
struct PatternGroup {
base: PathBuf,
/// The glob patterns, relative to `base`, with their directive positions
patterns: Vec<(usize, String)>,
/// Memoized "is this directory reachable for this group": like
/// `Resolver::auto_reachable`, but only `.gitignore` files at or below the base apply —
/// the static base is explicit, so everything above it is bypassed
reachable: Mutex<FxHashMap<PathBuf, bool>>,
}
impl Resolver {
fn new(sources: &Sources) -> Self {
let mut autos = vec![];
let mut externals = vec![];
let mut patterns: Vec<PatternGroup> = vec![];
let mut nots = vec![];
for (idx, source) in sources.iter().enumerate() {
match source {
SourceEntry::Auto { base } => autos.push((idx, base.clone())),
SourceEntry::External { base } => externals.push((idx, base.clone())),
SourceEntry::Pattern { base, pattern } => {
match patterns.iter_mut().find(|group| &group.base == base) {
Some(group) => group.patterns.push((idx, pattern.clone())),
None => patterns.push(PatternGroup {
base: base.clone(),
patterns: vec![(idx, pattern.clone())],
reachable: Default::default(),
}),
}
}
SourceEntry::Ignored { base, pattern } => nots.push(NotRule {
idx,
base: base.clone(),
pattern: pattern.clone(),
}),
}
}
Self {
autos,
externals,
patterns,
nots,
ignore_files: IgnoreFiles::default(),
auto_reachable: Default::default(),
external_reachable: Default::default(),
// If we are in a git repo then require it to ensure that only rules within
// the repo are used. For example, we don't want to consider a .gitignore file
// in the user's home folder if we're in a git repo.
//
// The alternative is using a call like `.parents(false)` but that will
// prevent looking at parent directories for .gitignore files from within
// the repo and that's not what we want.
//
// For example, in a project with this structure:
//
// home
// .gitignore
// my-project
// .gitignore
// apps
// .gitignore
// web
// {root}
//
// We do want to consider all .gitignore files listed:
// - home/.gitignore
// - my-project/.gitignore
// - my-project/apps/.gitignore
//
// However, if a repo is initialized inside my-project then only the following
// make sense for consideration:
// - my-project/.gitignore
// - my-project/apps/.gitignore
//
// Setting the require_git(true) flag conditionally allows us to do this.
for parent in first_root?.ancestors() {
if parent.join(".git").exists() {
builder.require_git(true);
break;
}
}
/// All source bases
fn bases(&self) -> impl Iterator<Item = &PathBuf> {
self.autos
.iter()
.map(|(_, base)| base)
.chain(self.externals.iter().map(|(_, base)| base))
.chain(self.patterns.iter().map(|group| &group.base))
for root in other_roots {
builder.add(root);
}
/// The walk roots: all bases that are not contained in another base. Nested bases are
/// reached by walking (the resolver keeps the path to an explicit base open).
fn walk_roots(&self) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = vec![];
for base in self.bases() {
if self
.bases()
.any(|other| other != base && base.starts_with(other))
{
continue;
}
if !roots.contains(base) {
roots.push(base.clone());
}
}
roots
// Setup auto source detection rules
for ignore in auto_source_detection::RULES.iter() {
builder.add_gitignore(ignore.clone());
}
/// Whether to keep the given walk entry
fn keep(&self, entry: &ignore::DirEntry) -> bool {
// Always keep the walk roots themselves; they are explicitly listed bases
if entry.depth() == 0 {
return true;
}
let path = entry.path();
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
if is_dir {
self.keep_dir(path)
} else {
self.keep_file(path)
// Setup ignores based on `@source` definitions
for (base, patterns) in ignores {
let mut ignore_builder = GitignoreBuilder::new(base);
for pattern in patterns {
ignore_builder.add_line(None, &pattern).unwrap();
}
let ignore = ignore_builder.build().unwrap();
builder.add_gitignore(ignore);
}
/// Whether to keep walking the given directory: either a source can contribute files
/// inside of it, or it is on the static path towards an explicitly listed base.
fn keep_dir(&self, dir: &Path) -> bool {
// A directory on the static path to an explicit base can always be entered (the base
// is explicit, so its ignoredness is bypassed), unless everything below it is excluded
// again by a later `@source not` directive.
let leads_to_base = |idx: usize, base: &PathBuf| {
base != dir
&& base.starts_with(dir)
&& !self
.nots
.iter()
.any(|not| not.idx > idx && not.matches(base))
};
if self.autos.iter().any(|(idx, base)| leads_to_base(*idx, base))
|| self
.externals
.iter()
.any(|(idx, base)| leads_to_base(*idx, base))
|| self.patterns.iter().any(|group| {
group
.patterns
.iter()
.any(|(idx, _)| leads_to_base(*idx, &group.base))
})
{
return true;
}
self.contributes_dir(dir)
}
/// Whether at least one source can contribute files inside the given directory. Unlike
/// [`Resolver::keep_dir`], directories that are only walked to reach an explicitly listed
/// base don't count: they are not part of any source's content, e.g. for the purpose of
/// generating file watcher globs.
fn contributes_dir(&self, dir: &Path) -> bool {
// Some source must be able to contribute files inside the directory, and not be
// overridden by a later `@source not` directive.
let not_after = |idx: usize| {
self.nots
.iter()
.any(|not| not.idx > idx && not.matches(dir))
};
if self
.autos
.iter()
.any(|(idx, _)| self.auto_reachable(dir) && !not_after(*idx))
{
return true;
}
if self
.externals
.iter()
.any(|(idx, _)| self.external_reachable(dir) && !not_after(*idx))
{
return true;
}
self.patterns.iter().any(|group| {
dir.strip_prefix(&group.base).is_ok_and(|remainder| {
group.patterns.iter().any(|(idx, pattern)| {
dir_could_contain_matches(pattern, remainder) && !not_after(*idx)
}) && self.pattern_reachable(group, dir)
})
})
}
/// Whether at least one source includes the given file
fn keep_file(&self, file: &Path) -> bool {
let Some(parent) = file.parent() else {
return false;
};
let not_after = |idx: usize| {
self.nots
.iter()
.any(|not| not.idx > idx && not.matches(file))
};
// Auto sources: the file must pass the default rules and the gitignore chain
if self.autos.iter().any(|(idx, base)| {
file.starts_with(base)
&& self.auto_reachable(parent)
&& !is_ignored_by_default_rules(file, false)
&& !self
.ignore_files
.is_ignored(file, false, parent, Boundary::None)
&& !not_after(*idx)
}) {
return true;
}
// External sources: like auto sources, but only `.gitignore` files strictly below the
// base apply. Rules from at or above the base are bypassed — they are what made the
// directory ignored, and it was listed explicitly anyway — while `.gitignore` files
// deeper inside the external tree still apply.
if self.externals.iter().any(|(idx, base)| {
file.starts_with(base)
&& self.external_reachable(parent)
&& !is_ignored_by_default_rules(file, false)
&& !self
.ignore_files
.is_ignored(file, false, parent, Boundary::Inside(base))
&& !not_after(*idx)
}) {
return true;
}
// Pattern sources: the file must match a glob. A match beats file-level gitignore
// rules — you were explicit about wanting files of that shape — and the default file
// rules only apply when the pattern isn't explicit about the file's shape.
self.patterns.iter().any(|group| {
file.strip_prefix(&group.base).is_ok_and(|remainder| {
let remainder = rooted_posix(remainder);
group.patterns.iter().any(|(idx, pattern)| {
glob_match(pattern, remainder.as_bytes())
&& (pattern_bypasses_default_file_rules(pattern)
|| !is_ignored_by_default_rules(file, false))
&& !not_after(*idx)
}) && self.pattern_reachable(group, parent)
})
})
}
/// Whether the given directory is reachable for an auto source: every directory on the
/// path from the auto base down to it passes the default rules and the gitignore chain.
fn auto_reachable(&self, dir: &Path) -> bool {
reachable(
&self.auto_reachable,
dir,
|dir| self.autos.iter().any(|(_, base)| base == dir),
|dir| {
!is_ignored_by_default_rules(dir, true)
&& dir.parent().is_some_and(|parent| {
!self.ignore_files.is_ignored(dir, true, parent, Boundary::None)
})
},
)
}
/// Like [`Resolver::auto_reachable`], but for external sources: only `.gitignore` files
/// strictly below the external base apply (see [`Boundary`]). Rules from at or above the
/// base are bypassed — they are what made the directory ignored, and it was listed
/// explicitly anyway.
///
/// When external bases are nested, the deepest base containing the directory bounds the
/// chain: reachability from an outer base implies reachability from a nested base (the
/// outer chain checks a superset of the ignore files), so this computes the union of the
/// per-base verdicts.
fn external_reachable(&self, dir: &Path) -> bool {
reachable(
&self.external_reachable,
dir,
|dir| self.externals.iter().any(|(_, base)| base == dir),
|dir| {
!is_ignored_by_default_rules(dir, true)
&& dir.parent().is_some_and(|parent| {
let boundary = self
.externals
.iter()
.map(|(_, base)| base)
.filter(|base| dir.starts_with(base))
.max_by_key(|base| base.components().count())
.map_or(Boundary::None, |base| Boundary::Inside(base));
!self.ignore_files.is_ignored(dir, true, parent, boundary)
})
},
)
}
/// Like [`Resolver::auto_reachable`], but for a pattern group: only `.gitignore` files at
/// or below the base apply — the static base is explicit, so everything above it is
/// bypassed.
fn pattern_reachable(&self, group: &PatternGroup, dir: &Path) -> bool {
reachable(
&group.reachable,
dir,
|dir| dir == group.base,
|dir| {
!is_ignored_by_default_rules(dir, true)
&& dir.parent().is_some_and(|parent| {
!self
.ignore_files
.is_ignored(dir, true, parent, Boundary::At(&group.base))
})
},
)
}
}
/// Whether every directory on the path from a source base down to `dir` passes the source's
/// `enter` rule. Bases themselves are always reachable: they are explicitly listed (and an
/// auto base that is itself ignored would have been promoted to an external source). Verdicts
/// are memoized per directory.
fn reachable(
memo: &Mutex<FxHashMap<PathBuf, bool>>,
dir: &Path,
is_base: impl Fn(&Path) -> bool,
enter: impl Fn(&Path) -> bool,
) -> bool {
// Walk up to the nearest base or directory with a memoized verdict…
let mut pending = vec![];
let mut current = dir;
let mut reachable = loop {
if is_base(current) {
break true;
}
if let Some(reachable) = memo.lock().unwrap().get(current) {
break *reachable;
}
pending.push(current.to_path_buf());
match current.parent() {
Some(parent) => current = parent,
// Reached the file system root without finding a base
None => break false,
}
};
// …then fill in the verdicts back down towards `dir`
for dir in pending.into_iter().rev() {
reachable = reachable && enter(&dir);
memo.lock().unwrap().insert(dir, reachable);
}
reachable
}
/// A lazily-loaded cache of the on-disk ignore files (`.gitignore`, `.ignore` and the
/// repository's `.git/info/exclude`).
#[derive(Debug, Default)]
struct IgnoreFiles {
/// The matcher chains per directory: all matchers that apply to paths inside the
/// directory, deepest first, from the directory itself up to the repository root (or the
/// file system root outside of a git repository, matching `git init`-less projects where
/// all ancestor `.gitignore` files apply)
chains: Mutex<FxHashMap<PathBuf, Arc<Vec<Arc<Gitignore>>>>>,
}
/// Which part of the ignore file chain applies to a source, anchored at its base:
///
/// - `Auto` sources respect the full chain, up to the git repository root
/// - `Pattern` sources respect ignore files at or below their base (the static base is
/// explicit, everything above it is bypassed)
/// - `External` sources respect ignore files strictly below their base: the base's own
/// ignore file is part of its bypassed ignoredness — inside ignored trees it is typically
/// the self-ignoring `*` file that generators drop into the directory — while deeper ignore
/// files are deliberate signals about specific contents and still apply
#[derive(Debug, Clone, Copy)]
enum Boundary<'a> {
None,
At(&'a Path),
Inside(&'a Path),
}
impl Boundary<'_> {
/// Whether an ignore file rooted at the given directory applies
fn applies_to(&self, dir: &Path) -> bool {
match self {
Boundary::None => true,
Boundary::At(base) => dir.starts_with(base),
Boundary::Inside(base) => dir != *base && dir.starts_with(base),
}
}
}
impl IgnoreFiles {
/// Whether the ignore files definitively ignore the given path. `dir` is the directory
/// containing the path, and `boundary` restricts which ignore files of the chain apply.
///
/// The deepest ignore file with a definitive answer wins, matching git's precedence, so a
/// path that a deeper ignore file re-includes via a `!` pattern is not ignored.
fn is_ignored(&self, path: &Path, is_dir: bool, dir: &Path, boundary: Boundary) -> bool {
for matcher in self.chain(dir).iter() {
if !boundary.applies_to(matcher.path()) {
// Chains are ordered deepest first, so nothing below the boundary can follow
break;
}
match matcher.matched(path, is_dir) {
ignore::Match::Ignore(_) => return true,
ignore::Match::Whitelist(_) => return false,
ignore::Match::None => {}
}
}
false
}
/// The matcher chain for paths inside the given directory: the directory's own matcher
/// first, then its parents' matchers, up to and including the git repository root.
fn chain(&self, dir: &Path) -> Arc<Vec<Arc<Gitignore>>> {
if let Some(chain) = self.chains.lock().unwrap().get(dir) {
return chain.clone();
}
let is_repo_root = dir.join(".git").exists();
let mut chain = vec![];
if let Some(matcher) = load_ignore_files(dir, is_repo_root) {
chain.push(matcher);
}
// Stop at the git repository root so that ignore files outside of the repository are
// not considered. Without a repository, all ancestor ignore files apply.
if !is_repo_root {
if let Some(parent) = dir.parent() {
chain.extend(self.chain(parent).iter().cloned());
}
}
let chain = Arc::new(chain);
self.chains
.lock()
.unwrap()
.insert(dir.to_path_buf(), chain.clone());
chain
}
}
/// Compile the ignore rules of the given directory, combining (from low to high precedence)
/// the repository's `.git/info/exclude`, the `.gitignore` file, and the `.ignore` file.
fn load_ignore_files(dir: &Path, is_repo_root: bool) -> Option<Arc<Gitignore>> {
let mut builder = GitignoreBuilder::new(dir);
let mut any = false;
let mut add = |file: PathBuf| {
if file.is_file() {
// I/O errors and partially invalid ignore files are ignored, matching the
// walker's behavior.
let _ = builder.add(file);
any = true;
}
};
if is_repo_root {
add(dir.join(".git").join("info").join("exclude"));
}
add(dir.join(".gitignore"));
add(dir.join(".ignore"));
if any {
builder.build().ok().map(Arc::new)
} else {
None
}
}
/// Whether a path is ignored by the default auto source detection rules: directories like
/// `node_modules`, binary and irrelevant extensions, lock files, … For directories only the
/// directory's own name is checked; the path towards it is checked by the reachability
/// helpers one directory at a time.
fn is_ignored_by_default_rules(path: &Path, is_dir: bool) -> bool {
auto_source_detection::RULES
// Pre-compute source matching data to avoid allocations in the hot filter_entry path
let auto_bases: Vec<PathBuf> = sources
.iter()
.any(|ignore| ignore.matched(path, is_dir).is_ignore())
}
/// An `@source not` directive, together with its position.
#[derive(Debug, Clone)]
struct NotRule {
/// Position of the `@source not` directive
idx: usize,
base: PathBuf,
/// The glob pattern, relative to `base`, e.g. `/ignored/**/*`
pattern: String,
}
impl NotRule {
/// Whether this directive excludes the given path: a file, or a directory and thereby
/// everything inside of it.
///
/// Like a gitignore rule, the pattern excludes a whole subtree when it matches a
/// directory, so besides the path itself every ancestor directory (up to the directive's
/// base) is tested as well. E.g. `@source not "./src/ba*"` excludes `src/bar/index.html`
/// because `/ba*` matches the `src/bar` directory. Note that directory-shaped directives
/// (`@source not "./some/dir"`) are normalized to a `/**/*` pattern with the directory as
/// its base, which matches everything inside the directory directly.
fn matches(&self, path: &Path) -> bool {
let Ok(remainder) = path.strip_prefix(&self.base) else {
return false;
};
// A directory-shaped directive (normalized to a `/**/*` pattern) also excludes the
// base directory itself, not just its contents, so the directory can be pruned.
if remainder.as_os_str().is_empty() {
return self.pattern == "/**/*";
}
remainder.ancestors().any(|prefix| {
!prefix.as_os_str().is_empty()
&& glob_match(&self.pattern, rooted_posix(prefix).as_bytes())
.filter_map(|source| match source {
SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()),
_ => None,
})
}
}
/// Serialize a path relative to some base as a `/`-rooted posix style string, e.g.
/// `/ba*/index.html`, matching how source patterns are stored.
fn rooted_posix(path: &Path) -> String {
let posix = crate::scanner::sources::path_to_posix_string(path);
if posix.starts_with('/') {
posix
} else {
format!("/{posix}")
}
}
/// Whether a directory (relative to the pattern's base) can contain files matching the pattern.
/// Used to prune directories that can never contribute, e.g. for `/ba*/*.html` only `ba*`
/// directories are entered.
fn dir_could_contain_matches(pattern: &str, dir: &Path) -> bool {
let pattern_components: Vec<&str> = pattern
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
for (i, component) in dir.components().enumerate() {
let component = component.as_os_str().to_string_lossy();
let pattern_sources: Vec<(PathBuf, String)> = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())),
_ => None,
})
.collect();
// Once we see a `**` everything nested can contain matches
match pattern_components.get(i) {
Some(&"**") => return true,
// The last pattern component matches files, not directories. A directory nested
// deeper than the pattern's directory part can never contain matches.
Some(_) if i + 1 >= pattern_components.len() => return false,
Some(pattern_component) => {
if !glob_match(pattern_component, component.as_bytes()) {
return false;
// Source pattern matching filter (lock-free, safe for parallel walking)
builder.filter_entry(move |entry| {
let path = entry.path();
// Ensure the entries are matching any of the provided source patterns (this is
// necessary for manual-patterns that can filter the file extension)
if path.is_file() {
let mut matches = false;
for base in &auto_bases {
if path.starts_with(base) {
matches = true;
break;
}
}
None => return false,
if !matches {
for (base, pattern) in &pattern_sources {
let remainder = path.strip_prefix(base);
if remainder.is_ok_and(|remainder| {
let mut path_str = remainder.to_string_lossy().to_string();
if !path_str.starts_with("/") {
path_str = format!("/{path_str}");
}
glob_match(pattern, path_str.as_bytes())
}) {
matches = true;
break;
}
}
}
if !matches {
return false;
}
}
}
true
}
true
});
/// Whether a pattern is explicit enough to bypass the default file rules.
///
/// A pattern without any wildcards names a concrete file, e.g. `/.env` or
/// `/do-include-me.bin` — you asked for exactly this file, so the default rules never apply.
/// A pattern that pins a specific extension, e.g. `/*.html` or `/**/*.bin`, bypasses them as
/// well. Patterns that do neither (e.g. `/blog/*/**/*`) keep the default file rules applied.
fn pattern_bypasses_default_file_rules(pattern: &str) -> bool {
// Concrete file, no wildcards (braces have already been expanded away)
if !pattern.contains(['*', '?', '[']) {
return true;
}
// Pinned extension
match Path::new(pattern).extension().and_then(|ext| ext.to_str()) {
Some(ext) => !ext.contains(['*', '?', '[']),
None => false,
}
Some(builder)
}
#[cfg(test)]

View file

@ -1,5 +1,6 @@
use crate::GlobEntry;
use bexpand::Expression;
use fxhash::FxHashMap;
use fxhash::{FxHashMap, FxHashSet};
use ignore::gitignore::Gitignore;
use std::path::{Component, Path, PathBuf};
use tracing::{event, Level};
@ -30,24 +31,6 @@ pub enum SourceEntry {
/// ```
Auto { base: PathBuf },
/// An `Auto` source whose directory is itself ignored (by the default rules, e.g.
/// `node_modules`, or by a `.gitignore`) but was explicitly listed anyway.
///
/// Represented by:
///
/// ```css
/// @source "../node_modules/my-lib";`
/// @source "../node_modules/my-lib/**/*";`
/// ```
///
/// Being explicit bypasses the ignoredness of the directory: everything inside is scanned
/// as if it were a regular auto source, except that `.gitignore` files from at or above
/// the directory no longer apply — they (including the self-ignoring `*` file that
/// generators typically place inside such directories) are what made it ignored in the
/// first place. `.gitignore` files *deeper inside* the directory still apply, and so do
/// the default rules, so e.g. nested `node_modules` stay ignored.
External { base: PathBuf },
/// Explicit source pattern regardless of any auto source detection rules
///
/// Represented by:
@ -65,11 +48,18 @@ pub enum SourceEntry {
/// @source not "src";`
/// @source not "src/**/*.html";`
/// ```
///
/// Note that directory-shaped directives (`@source not "src"`) are normalized to
/// `base: "src", pattern: "/**/*"`, which is semantically identical: everything under the
/// directory is ignored.
Ignored { base: PathBuf, pattern: String },
/// External sources are directories that are ignored (by us or .gitignore rules), but should be
/// included bypassing the default ignore rules.
///
/// Represented by:
///
/// ```css
/// @source "../node_modules/my-lib";`
/// @source "../node_modules/my-lib/**/*";`
/// ```
External { base: PathBuf },
}
#[derive(Debug, Clone, Default)]
@ -87,6 +77,164 @@ impl Sources {
}
}
/// When dealing with a pattern, then it could be that we end up with:
///
/// ```json
/// { base: '/some/folder', pattern: 'foo.ts' }
/// ```
///
/// If we just emit `!foo.ts` for the `/some/folder` path, then _everything_ else in that folder
/// would still be walked (but the result will be ignored).
///
/// Instead, we have to ensure that we ignore everything in that folder _except_ for the `foo.ts`
/// pattern.
///
/// This should be equivalent to:
/// ```gitignore
/// *
/// !foo.ts
/// ```
///
/// However, we have to be careful that we don't start ignoring files/folders that already exist.
/// ```css
/// @source "./some/folder/foo.ts";
/// @source "./some/folder/bar.ts";
/// ```
/// Would result in:
/// ```json
/// { base: '/some/folder', pattern: 'foo.ts' }
/// { base: '/some/folder', pattern: 'bar.ts' }
/// ```
///
/// If we were to blindly emit `*` for each pattern, then the `.gitignore` equivalent would look like
/// this:
/// ```gitignore
/// *
/// !foo.ts
/// *
/// !bar.ts
/// ```
///
/// This would result in ignoring the `foo.ts` file as well. Therefore we only want to insert
/// this `*` pattern when nothing else exists yet.
///
/// There is another problem that we need to solve. Let's say you have a pattern that contains a `*`
/// in the pattern:
/// ```css
/// @source './src/ba*/*.html';
/// ```
///
/// This would result in
/// ```json
/// { base: '/src', pattern: '/ba*/*.html' }
/// ```
///
/// If we now inject the `*` pattern for the `/src` folder, then we wouldn't scan any `ba*` folders
/// (e.g. `bar` or `baz`). For this, we have to make sure that we add inverse patterns for these
/// folders. This would essentially result in:
/// ```gitignore
/// * ← ignore everything
/// !/ba*/ ← except for the `ba*/` pattern, so we scan these folders
/// !/ba*/*.html ← then ensure we scan the `*.html` files in it as well
/// ```
///
fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
let unrestricted_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()),
SourceEntry::Pattern { base, pattern } if pattern.contains("**") => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
// Bases of restricted patterns. Each of these becomes its own walk root with its own
// `*` + `!<pattern>` rules, so an ancestor base must not ignore them recursively.
let pattern_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, .. } => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
let mut restricted_roots: FxHashSet<PathBuf> = FxHashSet::default();
let mut expanded = vec![];
for source in sources {
let SourceEntry::Pattern { base, pattern } = &source else {
expanded.push(source);
continue;
};
// `base` is already included by another `@source` that we know should be walked. This
// includes the case where `base` is _nested_ inside such a root, because everything under
// an unrestricted root is walked already. Restricting it would incorrectly hide siblings
// that the broader source is supposed to pick up.
if unrestricted_roots.iter().any(|root| base.starts_with(root)) {
expanded.push(source);
continue;
}
// Ignore everything in the directory. We will later add the specific patterns we are
// interested in.
if restricted_roots.insert(base.clone()) {
// When another source root is nested inside this base — an unrestricted root, or the
// base of another restricted pattern (which is walked from its own root with its own
// rules) — only ignore direct children so the nested root can still be walked.
let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base))
|| pattern_roots
.iter()
.any(|root| root != base && root.starts_with(base));
let pattern = if has_nested_root { "/*" } else { "*" };
expanded.push(SourceEntry::Ignored {
base: base.clone(),
pattern: pattern.to_owned(),
});
}
// Ensure to _include_ parent paths, otherwise the `*` from above would block walking the
// folders that need to be walked.
//
// ```css
// @source './src/ba*/*.html';
// ```
//
// ```gitignore
// * ← added by the above rule
// !/ba*/ ← this is what we're focusing on in this block
// !/ba*/*.html ← this is added later
// ```
{
let mut dir = PathBuf::new();
let mut components = Path::new(pattern).components().peekable();
while let Some(component) = components.next() {
if components.peek().is_none() {
break;
}
match component {
Component::Prefix(_) | Component::RootDir | Component::CurDir => continue,
Component::ParentDir | Component::Normal(_) => dir.push(component),
}
expanded.push(SourceEntry::Ignored {
base: base.clone(),
pattern: format!("!/{}/", path_to_posix_string(&dir).trim_start_matches('/')),
});
}
}
// Track the original source
expanded.push(source);
}
expanded
}
impl PublicSourceEntry {
/// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the
/// base of the PublicSourceEntry.
@ -192,16 +340,10 @@ impl PublicSourceEntry {
else if !self.pattern.starts_with("/") {
self.pattern = format!("/{}", self.pattern);
}
// `src/**` means everything underneath `src`, just like `src/**/*` and `src` do.
// Normalize it so all three are classified as auto source detection.
if self.pattern == "/**" {
self.pattern = "/**/*".to_owned();
}
}
}
pub(crate) fn path_to_posix_string(path: &Path) -> String {
fn path_to_posix_string(path: &Path) -> String {
let mut parts = Vec::new();
let mut is_rooted = false;
@ -332,32 +474,65 @@ mod tests {
}
#[test]
fn optimize_normalizes_double_star_to_auto_source_detection() {
fn concrete_patterns_are_expanded_to_restrict_their_base() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/**".to_string(),
negated: false,
};
source.optimize();
assert_eq!(source.pattern, "/**/*");
// …and therefore `src/**` is classified as an auto source, like `src/**/*` and `src`
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/**".to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
}]);
assert_eq!(sources, vec![SourceEntry::Auto { base }]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Pattern {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
#[test]
fn sources_are_converted_in_order() {
fn restricted_patterns_include_parent_directory_allow_rules() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/ef*/*.html".to_string(),
negated: false,
}]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Ignored {
base: base.clone(),
pattern: "!/ef*/".to_string(),
},
SourceEntry::Pattern {
base,
pattern: "/ef*/*.html".to_string(),
},
]
);
}
#[test]
fn unrestricted_sources_do_not_expand_patterns_for_the_same_base() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
@ -373,6 +548,72 @@ mod tests {
pattern: "src/foo.html".to_string(),
negated: false,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Auto { base: base.clone() },
SourceEntry::Pattern {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
#[test]
fn restricted_parent_bases_do_not_open_unrelated_siblings() {
let dir = tempdir().unwrap();
let project = dir.path().join("Users").join("robin").join("docus-test");
fs::create_dir_all(&project).unwrap();
let users = dunce::canonicalize(dir.path().join("Users")).unwrap();
let project = dunce::canonicalize(project).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: project.to_string_lossy().to_string(),
pattern: "**/*".to_string(),
negated: false,
},
PublicSourceEntry {
base: project.to_string_lossy().to_string(),
pattern: "../../app.config.ts".to_string(),
negated: false,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Auto {
base: project.clone(),
},
SourceEntry::Ignored {
base: users.clone(),
pattern: "/*".to_string(),
},
SourceEntry::Pattern {
base: users,
pattern: "/app.config.ts".to_string(),
},
]
);
}
#[test]
fn restricted_patterns_preserve_source_order() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
},
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
@ -383,7 +624,10 @@ mod tests {
assert_eq!(
sources,
vec![
SourceEntry::Auto { base: base.clone() },
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Pattern {
base: base.clone(),
pattern: "/foo.html".to_string(),
@ -416,10 +660,7 @@ mod tests {
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::Auto { base }
);
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
}
#[test]
@ -429,10 +670,7 @@ mod tests {
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::External { base }
);
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
@ -446,10 +684,7 @@ mod tests {
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::External { base }
);
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
@ -463,54 +698,7 @@ mod tests {
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::External { base }
);
}
#[test]
fn folders_reincluded_by_a_deeper_gitignore_stay_auto_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap();
// The deepest `.gitignore` with a definitive answer wins: the re-include is reachable
// (no parent directory of `generated` is excluded), so `generated` is not ignored.
fs::create_dir_all(dir.path().join("packages").join("app")).unwrap();
fs::write(
dir.path().join("packages").join("app").join(".gitignore"),
"!generated/\n",
)
.unwrap();
let base = dir.path().join("packages").join("app").join("generated");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::Auto { base }
);
}
#[test]
fn folders_inside_excluded_directories_become_external_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "parent/\n").unwrap();
// This whitelist is unreachable: `parent` itself is excluded, so git never descends
// into it and the re-include of `child` has no effect.
fs::create_dir_all(dir.path().join("parent")).unwrap();
fs::write(dir.path().join("parent").join(".gitignore"), "!child/\n").unwrap();
let base = dir.path().join("parent").join("child");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::External { base }
);
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
@ -523,10 +711,7 @@ mod tests {
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(
auto_source_entry(&base),
SourceEntry::Auto { base }
);
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
}
}
@ -593,79 +778,74 @@ pub fn public_source_entries_to_private_source_entries(
.map(|public_source| {
let mut source: SourceEntry = public_source.into();
// Mark auto sources as external if their directory is gitignored
// Promote auto-sources to external sources if they were gitignored
if let SourceEntry::Auto { ref base } = source {
let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists());
// The chain of directories whose `.gitignore` files can apply: `base` itself and
// its ancestors, up to and including the git repository root so `.gitignore`
// files outside of the repo are not considered.
//
// Without a git repository there is no repository root to stop at. Stop once the
// directory contains the current working directory instead, so `.gitignore`
// files outside of the project (e.g. in the user's home directory) can never
// promote a source to an external source. Note that the file walker still
// applies those `.gitignore` files when deciding which files to scan.
let mut chain: Vec<&Path> = vec![];
// Walk up from the folder, applying each `.gitignore` relative to the directory
// that contains it (matching git), and stop at the git repository root so
// `.gitignore` files outside of the repo are not considered.
for dir in base.ancestors() {
chain.push(dir);
let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| {
let path = dir.join(".gitignore");
// `Gitignore::new` roots the matcher at the directory containing the file,
// so patterns match relative to it.
path.is_file().then(|| Gitignore::new(&path).0)
});
// Only `.gitignore` files in ancestors of `base` can ignore `base` itself.
// Patterns in `base`'s own `.gitignore` only match paths _inside_ `base`, never
// `base` itself (the file walker still applies them to `base`'s contents).
//
// Skipping `base`'s own `.gitignore` also prevents a false positive for
// whitelist style `.gitignore` files, because relativizing `base` against
// itself yields the empty path, which incorrectly matches `/*`.
//
// E.g.:
//
// ```gitignore
// /*
// !/.gitignore
// !/app
// !/public
// ```
//
// Everything inside `base` except `.gitignore`, `app` and `public` is ignored,
// but `base` itself is not.
if dir != base {
if let Some(gitignore) = gitignore {
if gitignore
.matched_path_or_any_parents(&base, true)
.is_ignore()
{
source = SourceEntry::External { base: base.into() };
break;
}
}
}
// Stop at the git repository root.
if dir.join(".git").exists() {
break;
}
// Without a git repository there is no repository root to stop at. Stop once
// the directory contains the current working directory instead, so `.gitignore`
// files outside of the project (e.g. in the user's home directory) can never
// promote a source to an external source. Note that the file walker still
// applies those `.gitignore` files when deciding which files to scan.
if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) {
break;
}
}
// Match git's semantics: a directory is ignored when the directory itself or any
// of its parent directories is excluded, and it is not possible to re-include a
// directory once a parent directory is excluded — git never descends into an
// excluded directory, so whitelist rules inside of it are unreachable.
//
// So walk the path from the top down (`chain` is ordered bottom-up: `base` at
// index 0, the boundary last) and decide for every directory along the way
// whether it is excluded. The first excluded directory settles it. For a single
// directory, only `.gitignore` files in its parent directories can match it (its
// own `.gitignore` only matches paths _inside_ of it), and the deepest
// `.gitignore` with a definitive answer wins, so a directory that is re-included
// by a deeper `!the-directory` pattern is not ignored, even when an ancestor
// `.gitignore` ignores it.
'prefixes: for i in (0..chain.len().saturating_sub(1)).rev() {
let prefix = chain[i];
for dir in &chain[i + 1..] {
let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| {
let path = dir.join(".gitignore");
// `Gitignore::new` roots the matcher at the directory containing the
// file, so patterns match relative to it.
path.is_file().then(|| Gitignore::new(&path).0)
});
let Some(gitignore) = gitignore else {
continue;
};
match gitignore.matched(prefix, true) {
ignore::Match::Ignore(_) => {
source = SourceEntry::External { base: base.into() };
break 'prefixes;
}
// Re-included; this directory is reachable, move on to the next one.
ignore::Match::Whitelist(_) => continue 'prefixes,
ignore::Match::None => {}
}
}
}
}
source
})
.collect::<Vec<SourceEntry>>();
sources
expand_restricted_patterns(sources)
}
/// Convert a public source entry to a source entry
@ -678,15 +858,8 @@ impl From<PublicSourceEntry> for SourceEntry {
};
}
// After a successful `optimize()` any trailing concrete directory has already been
// hoisted into the base, so a folder source always has the `/**/*` pattern. The
// `is_dir` check only matters when `optimize()` could not canonicalize the base and
// left the entry untouched. Note that the pinned leading `/` has to be stripped, since
// joining an absolute-looking path onto the base would discard the base entirely.
let auto = value.pattern == "/**/*"
|| PathBuf::from(&value.base)
.join(value.pattern.trim_start_matches('/'))
.is_dir();
let auto =
value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir();
if !auto {
return SourceEntry::Pattern {
@ -695,8 +868,6 @@ impl From<PublicSourceEntry> for SourceEntry {
};
}
// A directory inside e.g. `node_modules` is ignored by default, so listing it
// explicitly makes it an external source.
let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| {
value.base.contains(&format!(
"{}{}{}",
@ -718,3 +889,50 @@ impl From<PublicSourceEntry> for SourceEntry {
}
}
}
impl From<GlobEntry> for SourceEntry {
fn from(value: GlobEntry) -> Self {
SourceEntry::Pattern {
base: PathBuf::from(value.base),
pattern: value.pattern,
}
}
}
impl From<SourceEntry> for GlobEntry {
fn from(value: SourceEntry) -> Self {
match value {
SourceEntry::Auto { base } | SourceEntry::External { base } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: "**/*".into(),
},
SourceEntry::Pattern { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
SourceEntry::Ignored { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
}
}
}
impl From<&SourceEntry> for GlobEntry {
fn from(value: &SourceEntry) -> Self {
match value {
SourceEntry::Auto { base } | SourceEntry::External { base } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: "**/*".into(),
},
SourceEntry::Pattern { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
SourceEntry::Ignored { base, pattern } => GlobEntry {
base: base.to_string_lossy().into(),
pattern: pattern.clone(),
},
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -580,11 +580,11 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)
describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])(
describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])(
'Using Vite %s',
(version) => {
test(
'external source file changes trigger a full reload',
'external source file changes update the CSS',
{
fs: {
'package.json': json`{}`,
@ -661,26 +661,33 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
})
// Flush all messages so that we can be sure the next messages are from
// the file changes we're about to make
// Flush all messages so that we can be sure the next messages are
// from the file changes we're about to make
process.flush()
// Changing an external .php file should trigger a full reload
// Changing an external .php file hot-updates the generated CSS
{
await fs.write(
'project-b/src/index.php',
txt`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
// Ensure the page reloaded
// On Vite < 7.1, Vite itself hard-invalidates watched files that
// aren't part of the module graph and reloads the page.
//
// On newer versions nothing reloads the page: the CSS hot-updates
// through the regular pipeline because the changed file is a
// watch dependency of the CSS root.
//
// Reloading the page for external template changes is the
// responsibility of the backend integration (e.g. `laravel-vite-plugin`'s `refresh` option, or `vite-plugin-full-reload`).
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
if (version === '^6' || version === '7.0.8') {
await process.onStdout((m) => m.includes('page reload') && m.includes('index.php'))
} else {
await process.onStderr(
(m) => m.includes('vite:hmr (client)') && m.includes('index.php'),
)
await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css'))
}
await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php'))
// Ensure the styles were regenerated with the new content
let styles = await fetchStyles(url, '/index.html')
@ -853,7 +860,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`)
})
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
}
// The same holds for a custom file type as long as some file of the
@ -868,7 +874,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`)
})
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
}
// Changing a scanned stylesheet that is not part of the module graph
@ -893,23 +898,26 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
}
// Changing an external file (e.g. a PHP template) should still trigger
// a full reload. This must work even though `snippet.php` is part of
// the module graph via the `?raw` import: a query import only pulls
// the file's contents into the graph (and creates an untransformed
// module node for the underlying file), it is not evidence that Vite
// processes `.php` files as modules.
// Changing an external file (e.g. a PHP template) hot-updates the
// generated CSS but does not trigger a full reload either. Reloading the
// page for external template changes is the responsibility of the backend
// integration (e.g. `laravel-vite-plugin`'s `refresh` option, or
// `vite-plugin-full-reload`).
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
{
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
await fs.write(
'project-b/src/index.php',
html`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
await retryAssertion(async () => {
expect(await fs.read('project-a/hmr.log')).toContain('full-reload')
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
let styles = await fetchStyles(url, '/index.html')

View file

@ -0,0 +1,165 @@
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils'
test(
'dev mode',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"preact": "^10"
},
"devDependencies": {
"@preact/preset-vite": "^2",
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import preact from '@preact/preset-vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
tailwindcss(),
preact(),
{
// Log all HMR payloads to a file so the test can assert on them
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
`,
'src/main.tsx': ts`
import { render } from 'preact'
import { App } from './app'
render(<App />, document.getElementById('app')!)
`,
'src/app.tsx': ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
'src/index.css': css`@import 'tailwindcss';`,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
})
// Load the component modules, like a browser visiting the page would
await fetch(`${url}/src/main.tsx`)
await fetch(`${url}/src/app.tsx`)
// Editing a component keeps HMR intact: new classes are delivered through
// a regular update, not a full page reload (which would lose all state)
{
await fs.write(
'src/app.tsx',
ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline flex" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
})
expect(await fs.read('hmr.log')).toContain('"type":"update"')
expect(await fs.read('hmr.log')).not.toContain('full-reload')
}
// Changing a scanned file that is not part of the module graph (e.g.
// `package.json`, which package managers and other tooling write to while
// the dev server is running) should not trigger a full reload either —
// that would destroy client state. New candidates should still be picked
// up because the file is a watch dependency of the CSS root, so the CSS
// hot-updates through Vite's regular pipeline.
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
{
await fs.write(
'package.json',
txt`
{
"type": "module",
"description": "content-['package.json']",
"dependencies": {
"preact": "^10"
},
"devDependencies": {
"@preact/preset-vite": "^2",
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^",
"vite": "^8"
}
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`content-['package.json']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
}
},
)

View file

@ -376,7 +376,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Create a new compiler, given the new `input`
;[compiler, scanner] = await createCompiler(input, I)
// Succesfully created a new compiler, so the `fullRebuildPaths`
// Successfully created a new compiler, so the `fullRebuildPaths`
// will be updated. If other errors occur, we should be able to
// restore the paths unconditionally.
backupRebuildPaths = fullRebuildPaths.slice()

View file

@ -61,10 +61,14 @@ export function optimize(
map = result.map?.toString()
result.warnings = result.warnings.filter((warning) => {
// Ignore warnings about unknown pseudo-classes as they are likely caused
// by the use of `:deep()`, `:slotted()`, and `:global()` which are not
// standard CSS but are commonly used in frameworks like Vue.
if (/'(deep|slotted|global)' is not recognized as a valid pseudo-/.test(warning.message)) {
// Ignore warnings about unknown pseudo-classes that are used in frameworks
// such as Vue or Angular and are handled by their own compilers, before
// reaching the browser.
if (
/'(deep|slotted|global|ng-deep|host-context)' is not recognized as a valid pseudo-/.test(
warning.message,
)
) {
return false
}

View file

@ -1,28 +0,0 @@
import { expect, test } from 'vitest'
import tailwindcss from './index'
// Vite's experimental `bundledDev` mode calls `hotUpdate` without a `server`,
// so the handler must not dereference it.
//
// - https://github.com/vitejs/vite/discussions/22746
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
test('hotUpdate does not crash when Vite omits the server (bundledDev)', () => {
let plugin = tailwindcss().find((plugin) => plugin.name === '@tailwindcss/vite:generate:serve')!
let hotUpdate = plugin.hotUpdate as unknown as (options: {
file: string
modules: unknown[]
timestamp: number
server: undefined
}) => unknown
expect(() =>
hotUpdate.call(plugin, {
file: '/app/template.html',
modules: [{ type: 'asset', id: undefined }],
timestamp: Date.now(),
server: undefined,
}),
).not.toThrow()
})

View file

@ -9,23 +9,15 @@ import {
} from '@tailwindcss/node'
import { clearRequireCache } from '@tailwindcss/node/require-cache'
import { Scanner } from '@tailwindcss/oxide'
import { realpathSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import type {
Environment,
InternalResolveOptions,
Plugin,
ResolvedConfig,
ViteDevServer,
} from 'vite'
import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig } from 'vite'
import * as vite from 'vite'
const DEBUG = env.DEBUG
const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/
const COMMON_JS_PROXY_RE = /\?commonjs-proxy/
const INLINE_STYLE_ID_RE = /[?&]index=\d+\.css$/
const JS_EXTENSIONS_RE = /^\.[cm]?[jt]sx?$/
export type PluginOptions = {
/**
@ -73,17 +65,9 @@ function createCustomResolver(
}
export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
let rootsByEnv = new DefaultMap<string, Map<string, Root>>((env: string) => new Map())
// File extensions that Vite (or one of its plugins) has been seen to process
// as a module. Plugins don't get added or removed while the dev server is
// running (changing the Vite config restarts the server), so once we've seen
// evidence for a file type we don't need to scan the module graphs for it
// again.
let viteProcessedExtensions = new Set<string>()
let isSSR = false
let shouldOptimize = true
let minify = true
@ -196,10 +180,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
name: '@tailwindcss/vite:scan',
enforce: 'pre',
configureServer(server) {
servers.push(server)
},
async configResolved(_config) {
config = _config
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
@ -256,151 +236,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
return result
},
},
hotUpdate({ file, modules, timestamp, server }) {
// Vite's experimental `bundledDev` mode invokes `hotUpdate` without a
// `server`, so there are no sibling environments to inspect and no
// server-level `hot`/`ws` channel to reload through. Bail out early
// rather than dereferencing `undefined`.
//
// https://github.com/tailwindlabs/tailwindcss/issues/20378
if (!server) return
// Ensure full-reloads are triggered for files that are being watched by
// Tailwind but aren't part of the module graph (like PHP or HTML
// files). If we don't do this, then changes to those files won't
// trigger a reload at all since Vite doesn't know about them.
{
// It's a little bit confusing, because due to the `addWatchFile`
// calls, it _is_ part of the module graph but nothing is really
// handling those files. These modules typically have an id of
// undefined and/or have a type of 'asset'.
//
// If we call `addWatchFile` on a file that is part of the actual
// module graph, then we will see a module for it with a type of `js`
// and a type of `asset`. We are only interested if _all_ of them are
// missing an id and/or have a type of 'asset', which is a strong
// signal that the changed file is not being handled by Vite or any of
// the plugins.
//
// Note: in Vite v7.0.6 the modules here will have a type of `js`, not
// 'asset'. But it will also have a `HARD_INVALIDATED` state and will
// do a full page reload already.
//
// Empty modules can be skipped since it means it's not
// `addWatchFile`d and thus irrelevant to Tailwind.
let isExternalFile =
modules.length > 0 &&
modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return
// Skip files that Vite (or one of its plugins) processes as a
// module — in this environment (e.g. a lazily-loaded route that
// hasn't been visited yet) or in another one (e.g. an SSR-only
// module). Such a file can only affect the page through Vite's own
// pipeline, so a full reload would only destroy client state. Any
// changes to the generated CSS still go through the regular
// `css-update` flow because the file is registered via
// `addWatchFile`.
//
// If the file exists as a real module in another environment, then
// that environment is responsible for it. E.g. an SSR framework
// has its own server side hmr/reload mechanism when handling
// server only modules. See https://v6.vite.dev/guide/migration.html
// > Updates to an SSR-only module no longer triggers a full page reload in the client. ...
for (let environment of Object.values(server.environments)) {
if (environment.name === this.environment.name) continue
let modules = environment.moduleGraph.getModulesByFile(file)
if (modules) {
for (let mod of modules) {
if (mod.type !== 'asset') {
return
}
}
}
}
// Otherwise the file is not loaded as a module anywhere, so
// determine whether its file _type_ would be processed by Vite
// when requested by the browser (in which case the file just isn't
// loaded yet, e.g. a lazily-loaded route that hasn't been visited).
// Vite has no API to answer this without actually running the
// plugin pipeline, so instead:
//
// Files Vite handles natively (the JS/TS and CSS families) are always
// processed by Vite. This includes stylesheets that never show up as
// their own module because a framework plugin compiles them into a
// component (e.g. Angular), in which case that plugin owns their HMR.
let extension = path.extname(file)
if (JS_EXTENSIONS_RE.test(extension) || vite.isCSSRequest(file)) return
// For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an
// SSG plugin), if a file with the same extension exists as a real
// module in any environment's module graph, then a plugin evidently
// handles this file type and the changed file just isn't loaded
// (yet).
if (extension !== '') {
if (viteProcessedExtensions.has(extension)) return
for (let environment of Object.values(server.environments)) {
for (let mod of environment.moduleGraph.idToModuleMap.values()) {
if (!mod.file?.endsWith(extension)) continue
if (mod.type === 'asset') continue
// Only count modules that the plugin pipeline actually
// transformed. Vite also creates untransformed placeholder
// nodes (e.g. for the file underlying a `?raw` import) that
// are not evidence that a plugin handles this file type.
if (mod.transformResult == null) continue
// Similarly, ignore query imports (e.g. `./template.html?raw`,
// or the `?html-proxy` modules Vite creates for inline
// scripts): they pull a file's _contents_ into the graph
// without a plugin processing the file type. A scanned
// `.html` template must still trigger a full reload even if
// some other `.html` file is imported with `?raw`.
if (!mod.id || mod.id.includes('?')) continue
viteProcessedExtensions.add(extension)
return
}
}
}
for (let env of new Set([this.environment.name, 'client'])) {
let roots = rootsByEnv.get(env)
if (roots.size === 0) continue
// If the file is not being watched by any of the roots, then we can
// skip the reload since it's not relevant to Tailwind CSS.
if (!isScannedFile(file, modules, roots)) {
continue
}
// https://vite.dev/changes/hotupdate-hook#migration-guide
let invalidatedModules = new Set<vite.EnvironmentModuleNode>()
for (let mod of modules) {
this.environment.moduleGraph.invalidateModule(
mod,
invalidatedModules,
timestamp,
true,
)
}
if (env === this.environment.name) {
this.environment.hot.send({ type: 'full-reload' })
} else if (server.hot.send) {
server.hot.send({ type: 'full-reload' })
} else if (server.ws.send) {
server.ws.send({ type: 'full-reload' })
}
return []
}
}
},
},
{
@ -521,10 +356,6 @@ class Root {
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
) {}
get scannedFiles() {
return this.scanner?.files ?? []
}
// Generate the CSS for the root file. This can return false if the file is
// not considered a Tailwind root. When this happened, the root can be GCed.
public async generate(
@ -710,54 +541,3 @@ class Root {
return false
}
}
function isScannedFile(
file: string,
modules: vite.EnvironmentModuleNode[],
roots: Map<string, Root>,
) {
let seen = new Set()
let q = [...modules]
let checks = {
file,
get realpath() {
try {
let realpath = realpathSync(file)
Object.defineProperty(checks, 'realpath', { value: realpath })
return realpath
} catch {
return null
}
},
}
while (q.length > 0) {
let module = q.shift()!
if (seen.has(module)) continue
seen.add(module)
if (module.id) {
let root = roots.get(module.id)
if (root) {
// If the file is part of the scanned files for this root, then we know
// for sure that it's being watched by any of the Tailwind CSS roots. It
// doesn't matter which root it is since it's only used to know whether
// we should trigger a full reload or not.
if (
root.scannedFiles.includes(checks.file) ||
(checks.realpath && root.scannedFiles.includes(checks.realpath))
) {
return true
}
}
}
// Keep walking up the tree until we find a root.
for (let importer of module.importers) {
q.push(importer)
}
}
return false
}

View file

@ -516,6 +516,8 @@ it('should not parse functional utilities with multiple modifiers', () => {
utilities.functional('bg', () => [])
expect(run('bg-red-1/2/3', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/50/', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/50//foo', { utilities })).toMatchInlineSnapshot(`[]`)
})
it('should parse a utility with an arbitrary value', () => {
@ -1416,6 +1418,19 @@ it('should parse a functional variant with a modifier', () => {
`)
})
it('should not parse variants with multiple modifiers', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])
let variants = new Variants()
variants.static('hover', () => {})
variants.compound('group', Compounds.StyleRules, () => {})
expect(run('group-hover/foo:flex', { utilities, variants })).toHaveLength(1)
expect(run('group-hover/foo/:flex', { utilities, variants })).toEqual([])
expect(run('group-hover/foo//bar:flex', { utilities, variants })).toEqual([])
})
it('should parse a functional variant starting with @', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])

View file

@ -372,6 +372,14 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
}
}
// If there's more than one modifier, the utility is invalid.
//
// E.g.:
//
// - `bg-red-500/50/50`
let parts = segment(base, '/')
if (parts.length > 2) return
// Figure out the new base and the modifier segment if present.
//
// E.g.:
@ -381,14 +389,7 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
// ^^^^^^^^^^ -> Base without modifier
// ^^ -> Modifier segment
// ```
let [baseWithoutModifier, modifierSegment = null, additionalModifier] = segment(base, '/')
// If there's more than one modifier, the utility is invalid.
//
// E.g.:
//
// - `bg-red-500/50/50`
if (additionalModifier) return
let [baseWithoutModifier, modifierSegment = null] = parts
let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment)
@ -707,17 +708,18 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
// Static, functional and compound variants
{
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null, additionalModifier] = segment(variant, '/')
// If there's more than one modifier, the variant is invalid.
//
// E.g.:
//
// - `group-hover/foo/bar`
if (additionalModifier) return null
let parts = segment(variant, '/')
if (parts.length > 2) return null
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null] = parts
let roots = findRoots(variantWithoutModifier, (root) => {
return designSystem.variants.has(root)
@ -1096,7 +1098,7 @@ const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
node.value = ','
}
// Wrap custom functions starting with `--`, in parentheses if preceeded by
// Wrap custom functions starting with `--`, in parentheses if preceded by
// a symbol. E.g.: `calc(100%---spacing(2))` → `calc(100%-(--spacing(2)))`
else if (node.kind === 'function' && node.value.startsWith('--')) {
let idx = ctx.index

View file

@ -1638,4 +1638,26 @@ describe('regressions', () => {
'lg:flex',
])
})
// https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610
test('does not merge utilities whose theme variables resolve to CSS-wide keywords', async () => {
let designSystem = await __unstable__loadDesignSystem(
css`
@tailwind utilities;
@theme {
--foreground: unset;
--default: unset;
}
@theme inline {
--color-foreground: var(--foreground);
--color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent);
}
`,
{ base: __dirname },
)
expect(
designSystem.canonicalizeCandidates(['text-foreground/60', 'text-default-soft-hover']),
).toEqual(['text-foreground/60', 'text-default-soft-hover'])
})
})

View file

@ -2563,6 +2563,25 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
return ast
}
// Variables whose theme value is a CSS-wide keyword (e.g.: `unset`) are never
// inlined. These are typically registered as a placeholder to be re-assigned at
// runtime, so two variables that share such a value are not interchangeable.
//
// E.g.:
//
// ```css
// @theme {
// --foreground: unset;
// --background: unset;
// }
// ```
//
// Inlining would make `text-(--foreground)` and `text-(--background)` produce
// the same signature `color: unset`, even though they are different at runtime.
//
// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords
const CSS_WIDE_KEYWORDS = ['initial', 'inherit', 'revert', 'revert-layer', 'revert-rule', 'unset']
// Resolve theme values to their inlined value.
//
// E.g.:
@ -2578,8 +2597,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
// }
// ```
//
// Which conveniently will be equivalent to: `text-red-500` when we inline
// the value.
// Which conveniently will be equivalent to: `text-red-500` when we inline the
// value.
//
// Without inlining:
// ```css
@ -2595,13 +2614,13 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
// }
// ```
//
// Recently we made sure that utilities like `text-red-500` also generate
// the fallback value for usage in `@reference` mode.
// Recently we made sure that utilities like `text-red-500` also generate the
// fallback value for usage in `@reference` mode.
//
// The second assumption is that if you use `var(--key, fallback)` that
// happens to match a known variable _and_ its inlined value. Then we can
// replace it with the inlined variable. This allows us to handle custom
// `@theme` and `@theme inline` definitions.
// The second assumption is that if you use `var(--key, fallback)` that happens
// to match a known variable _and_ its inlined value. Then we can replace it
// with the inlined variable. This allows us to handle custom `@theme` and
// `@theme inline` definitions.
function resolveVariablesInValue(value: string, designSystem: DesignSystem): string {
let changed = false
let valueAst = ValueParser.parse(value)
@ -2630,6 +2649,9 @@ function resolveVariablesInValue(value: string, designSystem: DesignSystem): str
seen.add(variable)
if (variableValue === undefined) return // Couldn't resolve the variable
// CSS-wide keywords are never inlined
if (CSS_WIDE_KEYWORDS.includes(variableValue.toLowerCase())) return
// Inject variable fallbacks when no fallback is present yet.
//
// A fallback could consist of multiple values.

View file

@ -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;
}
}

View file

@ -31,6 +31,23 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
).toEqual([])
})
it('should end a comment at `*/` even when it is preceded by a `\\`', () => {
expect(
parse(css`
/* C:\temp\*/
.foo {
color: red;
}
`),
).toEqual([
{
kind: 'rule',
selector: '.foo',
nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }],
},
])
})
it('should parse a comment inside of a selector and ignore it', () => {
expect(
parse(css`
@ -448,6 +465,28 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
])
})
it('should end a comment in a custom property at `*/` even when it is preceded by a `\\`', () => {
expect(
parse(css`
--foo: /* C:\temp\*/ bar;
--bar: /* baz */ qux;
`),
).toEqual([
{
kind: 'declaration',
property: '--foo',
value: '/* C:\\temp\\*/ bar',
important: false,
},
{
kind: 'declaration',
property: '--bar',
value: '/* baz */ qux',
important: false,
},
])
})
it('should parse empty custom properties', () => {
expect(
parse(css`

View file

@ -128,19 +128,24 @@ export function parse(input: string, opts?: ParseOptions) {
// ^^^^^^^^^^^^^
// }
// ```
//
// The escape character `\` in comments are ignored, this means that a
// the end of a comment preceded by `\` does _not_ mean that the end of the
// comment is escaped and therefore we have to keep parsing.
//
// This is a valid comment:
// ```
// /*C:\*/
// ```
// See: https://www.w3.org/TR/css-syntax-3/#consume-comment
else if (currentChar === SLASH && input.charCodeAt(i + 1) === ASTERISK) {
let start = i
for (let j = i + 2; j < input.length; j++) {
peekChar = input.charCodeAt(j)
// Current character is a `\` therefore the next character is escaped.
if (peekChar === BACKSLASH) {
j += 1
}
// End of the comment
else if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) {
// End of the comment.
if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) {
i = j + 1
break
}
@ -224,13 +229,9 @@ export function parse(input: string, opts?: ParseOptions) {
else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) {
for (let k = j + 2; k < input.length; k++) {
peekChar = input.charCodeAt(k)
// Current character is a `\` therefore the next character is escaped.
if (peekChar === BACKSLASH) {
k += 1
}
// End of the comment
else if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) {
// End of the comment.
if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) {
j = k + 1
break
}

View file

@ -75,6 +75,8 @@ export function buildDesignSystem(
let variants = createVariants(theme)
let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem))
let cachedVariantOrder: Map<Variant, number> | null = null
let cachedVariantOrderSize = -1
let parsedCandidates = new DefaultMap((candidate) =>
Array.from(parseCandidate(candidate, designSystem)),
)
@ -188,6 +190,11 @@ export function buildDesignSystem(
},
getVariantOrder() {
// parsedVariants only grows, so the cache should be reset only when the size changes
if (cachedVariantOrder !== null && cachedVariantOrderSize === parsedVariants.size) {
return cachedVariantOrder
}
let variants = Array.from(parsedVariants.values())
variants.sort((a, z) => this.variants.compare(a, z))
@ -209,6 +216,9 @@ export function buildDesignSystem(
prevVariant = variant
}
cachedVariantOrder = order
cachedVariantOrderSize = parsedVariants.size
return order
},

View file

@ -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;
}
}

View file

@ -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)) &) }'],
[],
[],
])

View file

@ -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;
}

View file

@ -12154,18 +12154,28 @@ test('rounded', async () => {
"
`)
expect(
await run([
'-rounded',
'-rounded-full',
'-rounded-none',
'-rounded-sm',
'-rounded-[4px]',
'rounded/foo',
'rounded-full/foo',
'rounded-none/foo',
'rounded-sm/foo',
'rounded-[4px]/foo',
]),
await run(
[
'-rounded',
'-rounded-full',
'-rounded-none',
'-rounded-sm',
'-rounded-[4px]',
'rounded/foo',
'rounded-full/foo',
'rounded-none/foo',
'rounded-sm/foo',
'rounded-[4px]/foo',
'rounded-sm/[5]',
'rounded-sm/(--foo)',
],
css`
@theme {
--radius-sm: 0.125rem;
}
@tailwind utilities;
`,
),
).toEqual('')
})
@ -22373,6 +22383,11 @@ test('stroke', async () => {
// Width
'-stroke-0',
'stroke--1',
'stroke-0/foo',
'stroke-1/50',
'stroke-2/[0.5]',
'stroke-2/(--my-opacity)',
'stroke-[12px]/foo',
]),
).toEqual('')
})
@ -24398,82 +24413,90 @@ test('filter', async () => {
"
`)
expect(
await run([
'-filter',
'-filter-none',
'-filter-[var(--value)]',
'-blur-xl',
'-blur-[4px]',
'brightness--50',
'-brightness-50',
'-brightness-[1.23]',
'brightness-unknown',
'contrast--50',
'-contrast-50',
'-contrast-[1.23]',
'contrast-unknown',
'-grayscale',
'-grayscale-0',
'grayscale--1',
'-grayscale-[var(--value)]',
'grayscale-unknown',
'hue-rotate--5',
'hue-rotate-unknown',
'-invert',
'invert--5',
'-invert-0',
'-invert-[var(--value)]',
'invert-unknown',
'-drop-shadow-xl',
'-drop-shadow-[0_0_red]',
await run(
[
'-filter',
'-filter-none',
'-filter-[var(--value)]',
'-blur-xl',
'-blur-[4px]',
'brightness--50',
'-brightness-50',
'-brightness-[1.23]',
'brightness-unknown',
'contrast--50',
'-contrast-50',
'-contrast-[1.23]',
'contrast-unknown',
'-grayscale',
'-grayscale-0',
'grayscale--1',
'-grayscale-[var(--value)]',
'grayscale-unknown',
'hue-rotate--5',
'hue-rotate-unknown',
'-invert',
'invert--5',
'-invert-0',
'-invert-[var(--value)]',
'invert-unknown',
'-drop-shadow-xl',
'-drop-shadow-[0_0_red]',
'drop-shadow/foo',
'-drop-shadow/foo',
'-drop-shadow/25',
'-drop-shadow-red-500',
'drop-shadow-red-500/foo',
'-drop-shadow-red-500/foo',
'-drop-shadow-red-500/50',
'drop-shadow/foo',
'-drop-shadow/foo',
'-drop-shadow/25',
'-drop-shadow-red-500',
'drop-shadow-red-500/foo',
'-drop-shadow-red-500/foo',
'-drop-shadow-red-500/50',
'-saturate-0',
'saturate--5',
'-saturate-[1.75]',
'-saturate-[var(--value)]',
'saturate-saturate',
'-sepia',
'sepia--50',
'-sepia-0',
'-sepia-[50%]',
'-sepia-[var(--value)]',
'sepia-unknown',
'filter/foo',
'filter-none/foo',
'filter-[var(--value)]/foo',
'blur-xl/foo',
'blur-none/foo',
'blur-[4px]/foo',
'brightness-50/foo',
'brightness-[1.23]/foo',
'contrast-50/foo',
'contrast-[1.23]/foo',
'grayscale/foo',
'grayscale-0/foo',
'grayscale-[var(--value)]/foo',
'hue-rotate-15/foo',
'hue-rotate-[45deg]/foo',
'invert/foo',
'invert-0/foo',
'invert-[var(--value)]/foo',
'drop-shadow-xl/foo',
'drop-shadow-[0_0_red]/foo',
'saturate-0/foo',
'saturate-[1.75]/foo',
'saturate-[var(--value)]/foo',
'sepia/foo',
'sepia-0/foo',
'sepia-[50%]/foo',
'sepia-[var(--value)]/foo',
]),
'-saturate-0',
'saturate--5',
'-saturate-[1.75]',
'-saturate-[var(--value)]',
'saturate-saturate',
'-sepia',
'sepia--50',
'-sepia-0',
'-sepia-[50%]',
'-sepia-[var(--value)]',
'sepia-unknown',
'filter/foo',
'filter-none/foo',
'filter-[var(--value)]/foo',
'blur-xl/foo',
'blur-none/foo',
'blur-[4px]/foo',
'brightness-50/foo',
'brightness-[1.23]/foo',
'contrast-50/foo',
'contrast-[1.23]/foo',
'grayscale/foo',
'grayscale-0/foo',
'grayscale-[var(--value)]/foo',
'hue-rotate-15/foo',
'hue-rotate-[45deg]/foo',
'invert/foo',
'invert-0/foo',
'invert-[var(--value)]/foo',
'drop-shadow-xl/foo',
'drop-shadow-[0_0_red]/foo',
'saturate-0/foo',
'saturate-[1.75]/foo',
'saturate-[var(--value)]/foo',
'sepia/foo',
'sepia-0/foo',
'sepia-[50%]/foo',
'sepia-[var(--value)]/foo',
],
css`
@theme {
--drop-shadow: 0 1px 1px rgb(0 0 0 / 0.05);
}
@tailwind utilities;
`,
),
).toEqual('')
expect(
@ -27243,25 +27266,36 @@ test('text-shadow', async () => {
"
`)
expect(
await run([
'-shadow-xl',
'-shadow-none',
'-shadow-red-500',
'-shadow-red-500/50',
'-shadow-red-500/[0.5]',
'-shadow-red-500/[50%]',
'-shadow-current',
'-shadow-current/50',
'-shadow-current/[0.5]',
'-shadow-current/[50%]',
'-shadow-inherit',
'-shadow-transparent',
'-shadow-[#0088cc]',
'-shadow-[#0088cc]/50',
'-shadow-[#0088cc]/[0.5]',
'-shadow-[#0088cc]/[50%]',
'-shadow-[var(--value)]',
]),
await run(
[
'-text-shadow-sm',
'-text-shadow-none',
'-text-shadow-red-500',
'-text-shadow-red-500/50',
'-text-shadow-red-500/[0.5]',
'-text-shadow-red-500/[50%]',
'-text-shadow-current',
'-text-shadow-current/50',
'-text-shadow-current/[0.5]',
'-text-shadow-current/[50%]',
'-text-shadow-inherit',
'-text-shadow-transparent',
'-text-shadow-[#0088cc]',
'-text-shadow-[#0088cc]/50',
'-text-shadow-[#0088cc]/[0.5]',
'-text-shadow-[#0088cc]/[50%]',
'-text-shadow-[var(--value)]',
'text-shadow/foo',
'text-shadow-sm/foo',
],
css`
@theme {
--text-shadow: 0 1px 1px rgb(0 0 0 / 0.05);
--text-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06);
}
@tailwind utilities;
`,
),
).toEqual('')
})
@ -27725,25 +27759,38 @@ test('shadow', async () => {
"
`)
expect(
await run([
'-shadow-xl',
'-shadow-none',
'-shadow-red-500',
'-shadow-red-500/50',
'-shadow-red-500/[0.5]',
'-shadow-red-500/[50%]',
'-shadow-current',
'-shadow-current/50',
'-shadow-current/[0.5]',
'-shadow-current/[50%]',
'-shadow-inherit',
'-shadow-transparent',
'-shadow-[#0088cc]',
'-shadow-[#0088cc]/50',
'-shadow-[#0088cc]/[0.5]',
'-shadow-[#0088cc]/[50%]',
'-shadow-[var(--value)]',
]),
await run(
[
'-shadow-sm',
'-shadow-none',
'-shadow-red-500',
'-shadow-red-500/50',
'-shadow-red-500/[0.5]',
'-shadow-red-500/[50%]',
'-shadow-current',
'-shadow-current/50',
'-shadow-current/[0.5]',
'-shadow-current/[50%]',
'-shadow-inherit',
'-shadow-transparent',
'-shadow-[#0088cc]',
'-shadow-[#0088cc]/50',
'-shadow-[#0088cc]/[0.5]',
'-shadow-[#0088cc]/[50%]',
'-shadow-[var(--value)]',
'shadow/foo',
'shadow-sm/foo',
'shadow-[10px_10px]/foo',
'shadow-[#0088cc]/foo',
],
css`
@theme {
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
@tailwind utilities;
`,
),
).toEqual('')
})
@ -28216,25 +28263,37 @@ test('inset-shadow', async () => {
"
`)
expect(
await run([
'-inset-shadow-sm',
'-inset-shadow-none',
'-inset-shadow-red-500',
'-inset-shadow-red-500/50',
'-inset-shadow-red-500/[0.5]',
'-inset-shadow-red-500/[50%]',
'-inset-shadow-current',
'-inset-shadow-current/50',
'-inset-shadow-current/[0.5]',
'-inset-shadow-current/[50%]',
'-inset-shadow-inherit',
'-inset-shadow-transparent',
'-inset-shadow-[#0088cc]',
'-inset-shadow-[#0088cc]/50',
'-inset-shadow-[#0088cc]/[0.5]',
'-inset-shadow-[#0088cc]/[50%]',
'-inset-shadow-[var(--value)]',
]),
await run(
[
'-inset-shadow-sm',
'-inset-shadow-none',
'-inset-shadow-red-500',
'-inset-shadow-red-500/50',
'-inset-shadow-red-500/[0.5]',
'-inset-shadow-red-500/[50%]',
'-inset-shadow-current',
'-inset-shadow-current/50',
'-inset-shadow-current/[0.5]',
'-inset-shadow-current/[50%]',
'-inset-shadow-inherit',
'-inset-shadow-transparent',
'-inset-shadow-[#0088cc]',
'-inset-shadow-[#0088cc]/50',
'-inset-shadow-[#0088cc]/[0.5]',
'-inset-shadow-[#0088cc]/[50%]',
'-inset-shadow-[var(--value)]',
'inset-shadow/foo',
'inset-shadow-sm/foo',
'inset-shadow-[10px_10px]/foo',
],
css`
@theme {
--inset-shadow: inset 0 1px 3px 0 rgb(0 0 0 / 0.1);
--inset-shadow-sm: inset 0 1px 1px rgb(0 0 0 / 0.05);
}
@tailwind utilities;
`,
),
).toEqual('')
})

View file

@ -421,6 +421,13 @@ export function createUtilities(theme: Theme) {
desc.themeKeys ?? [],
)
// If the theme value resolved without consuming the modifier, then
// the modifier would be silently ignored and `w-4` and `w-4/foo`
// would generate the same CSS.
//
// The modifier would be consumed if it's a fraction, e.g.: `w-1/2`
if (value !== null && candidate.modifier && !candidate.value.fraction) return
// Automatically handle things like `w-1/2` without requiring `1/2` to
// exist as a theme value.
if (value === null && desc.supportsFractions && candidate.value.fraction) {
@ -3888,6 +3895,10 @@ export function createUtilities(theme: Theme) {
}
{
// A modifier is only valid as the opacity of a stroke color. The value
// did not resolve to a color, so a modifier would be silently ignored.
if (candidate.modifier) return
let value = theme.resolve(candidate.value.value, ['--stroke-width'])
if (value) {
return [decl('stroke-width', value)]
@ -4628,6 +4639,7 @@ export function createUtilities(theme: Theme) {
let value = theme.get(['--drop-shadow'])
let resolved = theme.resolve(null, ['--drop-shadow'])
if (value === null || resolved === null) return
if (candidate.modifier && !alpha) return
return [
filterProperties(),
@ -5436,6 +5448,7 @@ export function createUtilities(theme: Theme) {
if (!candidate.value) {
let value = theme.get(['--text-shadow'])
if (value === null) return
if (candidate.modifier && !alpha) return
return [
textShadowProperties(),
@ -5463,6 +5476,8 @@ export function createUtilities(theme: Theme) {
]
}
default: {
if (candidate.modifier && !alpha) return
return [
textShadowProperties(),
decl('--tw-text-shadow-alpha', alpha),
@ -5491,6 +5506,8 @@ export function createUtilities(theme: Theme) {
{
let value = theme.get([`--text-shadow-${candidate.value.value}`])
if (value) {
if (candidate.modifier && !alpha) return
return [
textShadowProperties(),
decl('--tw-text-shadow-alpha', alpha),
@ -5582,6 +5599,7 @@ export function createUtilities(theme: Theme) {
if (!candidate.value) {
let value = theme.get(['--shadow'])
if (value === null) return
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
@ -5611,6 +5629,8 @@ export function createUtilities(theme: Theme) {
]
}
default: {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-shadow-alpha', alpha),
@ -5644,6 +5664,8 @@ export function createUtilities(theme: Theme) {
{
let value = theme.get([`--shadow-${candidate.value.value}`])
if (value) {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-shadow-alpha', alpha),
@ -5708,6 +5730,7 @@ export function createUtilities(theme: Theme) {
if (!candidate.value) {
let value = theme.get(['--inset-shadow'])
if (value === null) return
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
@ -5737,6 +5760,8 @@ export function createUtilities(theme: Theme) {
]
}
default: {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-inset-shadow-alpha', alpha),
@ -5772,6 +5797,8 @@ export function createUtilities(theme: Theme) {
let value = theme.get([`--inset-shadow-${candidate.value.value}`])
if (value) {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-inset-shadow-alpha', alpha),

View file

@ -25,7 +25,7 @@ export function compareBreakpoints(a: string, z: string, direction: 'asc' | 'des
// Compare by bucket name
(aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) ||
// If bucket names are the same, compare by value
(direction === 'asc' ? parseInt(a) - parseInt(z) : parseInt(z) - parseInt(a))
(direction === 'asc' ? parseFloat(a) - parseFloat(z) : parseFloat(z) - parseFloat(a))
// If the groups are the same, and the contents are not numbers, the
// `order` will result in `NaN`. In this case, we want to make sorting

View file

@ -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;
}
"
@ -1424,6 +1437,80 @@ test('sorting `min` and `max` should sort by unit, then by value, then alphabeti
`)
})
test('sorting `min` and `max` should compare decimal values numerically', async () => {
expect(
await run([
'min-[40.5rem]:flex',
'min-[40.25rem]:flex',
'max-[40.25rem]:flex',
'max-[40.5rem]:flex',
]),
).toMatchInlineSnapshot(`
"
@media not all and (min-width: 40.5rem) {
.max-\\[40\\.5rem\\]\\:flex {
display: flex;
}
}
@media not all and (min-width: 40.25rem) {
.max-\\[40\\.25rem\\]\\:flex {
display: flex;
}
}
@media (min-width: 40.25rem) {
.min-\\[40\\.25rem\\]\\:flex {
display: flex;
}
}
@media (min-width: 40.5rem) {
.min-\\[40\\.5rem\\]\\:flex {
display: flex;
}
}
"
`)
})
test('sorting `@min` and `@max` should compare decimal values numerically', async () => {
expect(
await run([
'@min-[40.5rem]:flex',
'@min-[40.25rem]:flex',
'@max-[40.25rem]:flex',
'@max-[40.5rem]:flex',
]),
).toMatchInlineSnapshot(`
"
@container not (min-width: 40.5rem) {
.\\@max-\\[40\\.5rem\\]\\:flex {
display: flex;
}
}
@container not (min-width: 40.25rem) {
.\\@max-\\[40\\.25rem\\]\\:flex {
display: flex;
}
}
@container (min-width: 40.25rem) {
.\\@min-\\[40\\.25rem\\]\\:flex {
display: flex;
}
}
@container (min-width: 40.5rem) {
.\\@min-\\[40\\.5rem\\]\\:flex {
display: flex;
}
}
"
`)
})
test('supports', async () => {
expect(
await run([
@ -1435,6 +1522,17 @@ test('supports', async () => {
'supports-[font-tech(color-COLRv1)]:flex',
'supports-[var(--test)]:flex',
'supports-[--test]:flex',
// Only top-level and/or/not should have spaces around them. We should
// ignore and/or/not inside of `selector(…)`
'supports-[selector(a:not(.foo))]:flex',
'supports-[selector(a:is(.and,.or))]:flex',
'supports-[(display:grid)or(display:flex)]:grid',
'supports-[not(display:grid)]:flex',
'supports-[foo-not(display:grid)]:flex',
'supports-[selector([data-foo="("])or(display:grid)]:flex',
'supports-[selector(.foo\\(bar)or(display:grid)]:flex',
'supports-[((display:grid)or(display:flex))]:grid',
]),
).toMatchInlineSnapshot(`
"
@ -1444,12 +1542,24 @@ test('supports', async () => {
}
}
@supports (display: grid) or (display: flex) {
.supports-\\[\\(\\(display\\:grid\\)or\\(display\\:flex\\)\\)\\]\\:grid {
display: grid;
}
}
@supports (display: grid) and font-format(opentype) {
.supports-\\[\\(display\\:grid\\)_and_font-format\\(opentype\\)\\]\\:grid {
display: grid;
}
}
@supports (display: grid) or (display: flex) {
.supports-\\[\\(display\\:grid\\)or\\(display\\:flex\\)\\]\\:grid {
display: grid;
}
}
@supports (--test: var(--tw)) {
.supports-\\[--test\\]\\:flex {
display: flex;
@ -1474,12 +1584,48 @@ test('supports', async () => {
}
}
@supports foo-not(display:grid) {
.supports-\\[foo-not\\(display\\:grid\\)\\]\\:flex {
display: flex;
}
}
@supports not (display: grid) {
.supports-\\[not\\(display\\:grid\\)\\]\\:flex {
display: flex;
}
}
@supports selector(.foo\\(bar) or (display: grid) {
.supports-\\[selector\\(\\.foo\\\\\\(bar\\)or\\(display\\:grid\\)\\]\\:flex {
display: flex;
}
}
@supports selector(A > B) {
.supports-\\[selector\\(A_\\>_B\\)\\]\\:flex {
display: flex;
}
}
@supports selector([data-foo="("]) or (display: grid) {
.supports-\\[selector\\(\\[data-foo\\=\\"\\(\\"\\]\\)or\\(display\\:grid\\)\\]\\:flex {
display: flex;
}
}
@supports selector(a:is(.and,.or)) {
.supports-\\[selector\\(a\\:is\\(\\.and\\,\\.or\\)\\)\\]\\:flex {
display: flex;
}
}
@supports selector(a:not(.foo)) {
.supports-\\[selector\\(a\\:not\\(\\.foo\\)\\)\\]\\:flex {
display: flex;
}
}
@supports var(--test) {
.supports-\\[var\\(--test\\)\\]\\:flex {
display: flex;
@ -1764,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;
}
"
@ -1993,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;
}
"
@ -2046,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;
}
"
@ -2085,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;
}
"
@ -2584,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;
}
}
@ -2857,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;
}
"

View file

@ -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
@ -938,7 +945,22 @@ export function createVariants(theme: Theme): Variants {
if (/^[\w-]*\s*\(/.test(value)) {
// Chrome has a bug where `(condition1)or(condition2)` is not valid, but
// `(condition1) or (condition2)` is supported.
let query = value.replace(/\b(and|or|not)\b/g, ' $1 ')
let changed = false
let ast = ValueParser.parse(value)
walk(ast, (node) => {
if (node.kind !== 'function') return
// Leave selectors as-is, they could contain `selector(a:not(b))`, and
// in this case we don't want the space around the `not`.
if (node.value === 'selector') return WalkAction.Skip
if (node.value === 'and' || node.value === 'or' || node.value === 'not') {
changed = true
node.value = ` ${node.value} `
}
})
let query = changed ? ValueParser.toCss(ast) : value
ruleNode.nodes = [atRule('@supports', query, ruleNode.nodes)]
return

View file

@ -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`
<div id="target" class="${kind}-focus:z-10"></div>
<div id="applied" class="applied"></div>
`
let { getPropertyValue } = await render(
page,
kind === 'group'
? html`<div id="trigger" tabindex="0" class="group owner">${targets}</div>`
: html`<div class="owner">
<div id="trigger" tabindex="0" class="peer"></div>
${targets}
</div>`,
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`
<div id="trigger" tabindex="0" class="group">
<div id="target" class="target group-focus:z-10"></div>
</div>
`,
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`
<div class="group/${outer}" data-active>
<div id="inner" class="group/${inner}" data-active>
<div id="target" class="group-data-active/a:group-data-active/b:z-10"></div>
</div>
</div>
`,
)
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`
<div id="outer" tabindex="0" class="group">
<div id="peer" tabindex="0" class="peer"></div>
<div id="middle" class="group peer-group-focus:z-20">
<div id="target" class="group-peer-focus:z-10"></div>
</div>
</div>
`,
)
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')