From f302fce815786dedbb97baf81f666d9a28c55a24 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 17 Mar 2026 13:00:31 +0100 Subject: [PATCH] Fix canonicalization resulting in empty list (#19812) This PR fixes a bug in the canonicalization process where if a few utilities collapse into a smaller one, and the smaller one is part of the original list, then it results in an empty list. It will be more clear with an example. Let's say you have this setup: ``` w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 ``` The first step is that this will result in: ``` w-5 h-5 size-5 ``` Then the `w-5 h-5` can turn into `size-5`. But the existing `size-5`, can also be replaced by the `size-5`. Internally, when we have a replacement, then we mark all the classes that can be replaced as "droppable", so they would be dropped from the list. But in this scenario we also marked `size-5` as droppable, resulting in an empty list. If an additional class existed: ``` w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 flex ``` The result would be ``` flex ``` Instead of the expected: ``` size-5 flex ``` ## Test plan 1. Existing tests pass 2. Added new tests with and without an additional class --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 10 ++++++++++ .../tailwindcss/src/canonicalize-candidates.ts | 14 +++++++++----- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f174abca..4008c059c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745)) - Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790)) - Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809)) +- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812)) ## [4.2.1] - 2026-02-23 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index efcb351a3..4b6ecbfd6 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1061,6 +1061,16 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['w-128 h-128', 'size-128'], // `w-128` on its own would become `w-lg` ['mt-123 mb-123', 'my-123'], + // Collapse duplicates into themselves + ['w-8 w-8', 'w-8'], + + // `w-*` and `h-*` would canonicalize to `size-5` + // `size-5` and `size-5` should then canonicalize to `size-5` + ['w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5', 'size-5'], + + // Same as above, but with an additional unrelated class + ['w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 flex', 'size-5 flex'], + // Do not touch if not operating on the same variants ['hover:w-4 h-4', 'hover:w-4 h-4'], diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 8670dd2c1..566b4a3f7 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -446,13 +446,17 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st designSystem.storage[UTILITY_SIGNATURE_KEY].get(signatureOptions).get(replacement) if (signature !== collapsedSignature) continue // Not a safe replacement - // We can replace all items in the combo with the replacement - for (let item of combo) { - drop.add(candidates[item]) - } - // Use the replacement result.add(replacement) + + // We can replace all items in the combo with the replacement. If the + // replacement is already part of the combo, keep that one around. + for (let item of combo) { + if (candidates[item] !== replacement) { + drop.add(candidates[item]) + } + } + break } }