Skip to content

Commit 51aa9d7

Browse files
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>
1 parent c586bd6 commit 51aa9d7

File tree

3 files changed

+39
-1
lines changed

3 files changed

+39
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- Guard object lookups against inherited prototype properties ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725))
1717
- Canonicalize `calc(var(--spacing)*…)` expressions into `--spacing(…)` ([#19769](https://github.com/tailwindlabs/tailwindcss/pull/19769))
18+
- Fix crash in canonicalization step when handling utilities with empty property maps ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727))
1819

1920
## [4.2.1] - 2026-02-23
2021

packages/tailwindcss/src/canonicalize-candidates.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,3 +1220,40 @@ test('collapse canonicalization is not affected by previous calls', { timeout },
12201220
'size-4',
12211221
])
12221222
})
1223+
1224+
test('collapse does not crash when utilities with no standard properties are present', { timeout }, async () => {
1225+
let designSystem = await designSystems.get(__dirname).get(css`
1226+
@import 'tailwindcss';
1227+
`)
1228+
1229+
let options: CanonicalizeOptions = {
1230+
collapse: true,
1231+
logicalToPhysical: true,
1232+
rem: 16,
1233+
}
1234+
1235+
// Shadow utilities use CSS custom properties and @property rules but may
1236+
// produce empty property maps in the collapse algorithm. This should not
1237+
// crash with "Cannot read properties of null" or "X is not iterable".
1238+
expect(() =>
1239+
designSystem.canonicalizeCandidates(['shadow-sm', 'border'], options),
1240+
).not.toThrow()
1241+
1242+
expect(() =>
1243+
designSystem.canonicalizeCandidates(['shadow-md', 'p-4'], options),
1244+
).not.toThrow()
1245+
1246+
expect(() =>
1247+
designSystem.canonicalizeCandidates(['shadow-sm', 'shadow-md'], options),
1248+
).not.toThrow()
1249+
1250+
// Verify the candidates are returned (not collapsed, since shadows can't
1251+
// meaningfully collapse with unrelated utilities)
1252+
expect(
1253+
designSystem.canonicalizeCandidates(['shadow-sm', 'border'], options),
1254+
).toEqual(expect.arrayContaining(['shadow-sm', 'border']))
1255+
1256+
expect(
1257+
designSystem.canonicalizeCandidates(['shadow-sm', 'shadow-md'], options),
1258+
).toEqual(expect.arrayContaining(['shadow-sm', 'shadow-md']))
1259+
})

packages/tailwindcss/src/canonicalize-candidates.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st
334334
// all intersections with an empty set will remain empty.
335335
if (result!.size === 0) return result!
336336
}
337-
return result!
337+
return result ?? new Set<string>()
338338
})
339339

340340
// Link each candidate that could be linked via another utility

0 commit comments

Comments
 (0)