From 51aa9d799c59f79723ba032ed11c8010c4d490a3 Mon Sep 17 00:00:00 2001 From: Kirk Ouimet Date: Tue, 10 Mar 2026 09:48:07 -0600 Subject: [PATCH] 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 | 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() ``` 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 --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 37 +++++++++++++++++++ .../src/canonicalize-candidates.ts | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd32163d..c30de00ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index f1cb0c225..100d900a1 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -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'])) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index b2c54b749..d1b3b361b 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -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() }) // Link each candidate that could be linked via another utility