fix(canonicalize): handle utilities with empty property maps in collapse (#19727)

## Problem

`canonicalizeCandidates` crashes when called with `collapse: true` and
the candidate list includes utilities whose CSS output contains no
standard declaration properties (only `@property` rules and CSS custom
properties).

This is reproducible with vanilla Tailwind CSS and no custom
configuration:

```js
designSystem.canonicalizeCandidates(['shadow-sm', 'border'], { collapse: true })
// TypeError: X is not iterable
```

```js
designSystem.canonicalizeCandidates(['shadow-sm', 'border'], { collapse: true })
// TypeError: Cannot read properties of null (reading 'has')
```

All shadow utilities (`shadow-sm`, `shadow-md`, `shadow-lg`,
`shadow-xl`) crash when combined with any other utility and `collapse:
true`.

This was discovered via `eslint-plugin-better-tailwindcss`, which calls
`canonicalizeCandidates` with `collapse: true` for its
`enforce-canonical-classes` rule. The crash brings down ESLint entirely.

## Root cause

In `collapseGroup`, the `otherUtilities` array is built by mapping over
each candidate's property values:

```ts
let otherUtilities = candidatePropertiesValues.map((propertyValues) => {
  let result: Set<string> | null = null
  for (let property of propertyValues.keys()) {
    // ... builds result ...
  }
  return result! // returns null if propertyValues has no keys
})
```

When a utility like `shadow-sm` generates CSS with `@property` rules and
custom property declarations but no standard CSS properties,
`propertyValues.keys()` is empty, the loop never executes, and `result`
stays `null`. The non-null assertion `result!` returns `null` into the
array.

Downstream code then crashes when iterating or calling `.has()` on the
null entry:

```ts
for (let i = 0; i < otherUtilities.length; i++) {
  let current = otherUtilities[i]     // null
  for (let property of current) {     // "X is not iterable"
    if (other.has(property)) {        // "Cannot read properties of null"
```

## Fix

Return an empty `Set` instead of `null` when a utility has no property
keys:

```ts
return result ?? new Set<string>()
```

This is semantically correct: a utility with no standard properties
cannot be linked to or collapsed with any other utility, which is
exactly what an empty Set represents in the linking algorithm. It won't
cause false collapses or suppress valid collapses of other utilities.

## Test plan

- Added test: `collapse does not crash when utilities with no standard
properties are present`
- Verifies `shadow-sm + border`, `shadow-md + p-4`, and `shadow-sm +
shadow-md` don't throw
- Verifies the candidates are returned uncollapsed (correct behavior)
- All 1218 existing tests continue to pass

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Kirk Ouimet 2026-03-10 09:48:07 -06:00 • committed by GitHub
parent c586bd6a94
commit 51aa9d799c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 39 additions and 1 deletions

View file

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Guard object lookups against inherited prototype properties ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725))
- Canonicalize `calc(var(--spacing)*…)` expressions into `--spacing(…)` ([#19769](https://github.com/tailwindlabs/tailwindcss/pull/19769))
- Fix crash in canonicalization step when handling utilities with empty property maps ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727))
## [4.2.1] - 2026-02-23

View file

@ -1220,3 +1220,40 @@ test('collapse canonicalization is not affected by previous calls', { timeout },
'size-4',
])
})
test('collapse does not crash when utilities with no standard properties are present', { timeout }, async () => {
let designSystem = await designSystems.get(__dirname).get(css`
@import 'tailwindcss';
`)
let options: CanonicalizeOptions = {
collapse: true,
logicalToPhysical: true,
rem: 16,
}
// Shadow utilities use CSS custom properties and @property rules but may
// produce empty property maps in the collapse algorithm. This should not
// crash with "Cannot read properties of null" or "X is not iterable".
expect(() =>
designSystem.canonicalizeCandidates(['shadow-sm', 'border'], options),
).not.toThrow()
expect(() =>
designSystem.canonicalizeCandidates(['shadow-md', 'p-4'], options),
).not.toThrow()
expect(() =>
designSystem.canonicalizeCandidates(['shadow-sm', 'shadow-md'], options),
).not.toThrow()
// Verify the candidates are returned (not collapsed, since shadows can't
// meaningfully collapse with unrelated utilities)
expect(
designSystem.canonicalizeCandidates(['shadow-sm', 'border'], options),
).toEqual(expect.arrayContaining(['shadow-sm', 'border']))
expect(
designSystem.canonicalizeCandidates(['shadow-sm', 'shadow-md'], options),
).toEqual(expect.arrayContaining(['shadow-sm', 'shadow-md']))
})

View file

@ -334,7 +334,7 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
// all intersections with an empty set will remain empty.
if (result!.size === 0) return result!
}
return result!
return result ?? new Set<string>()
})
// Link each candidate that could be linked via another utility