From d6698ec5fb78915dc409c29887a55f204368c688 Mon Sep 17 00:00:00 2001 From: kirkouimet Date: Wed, 25 Feb 2026 17:21:23 -0700 Subject: [PATCH] fix(canonicalize): handle utilities with empty property maps in collapse When `canonicalizeCandidates` is called with `collapse: true`, the `collapseGroup` function builds an `otherUtilities` array by mapping each candidate's property values. If a utility generates CSS but has no standard declaration properties (e.g. shadow utilities that use `@property` rules and CSS custom properties), the inner loop over `propertyValues.keys()` never executes, leaving `result` as `null`. The non-null assertion `result!` then returns `null` into the array, causing downstream code to crash with "X is not iterable" or "Cannot read properties of null (reading 'has')" when iterating or calling methods on the null entry. Fix: return an empty Set instead of null when a utility has no property keys. 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. Reproduction: `canonicalizeCandidates(['shadow-sm', 'border'], { collapse: true })` crashes on vanilla Tailwind CSS with no custom configuration. --- .../src/canonicalize-candidates.test.ts | 37 +++++++++++++++++++ .../src/canonicalize-candidates.ts | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index fae944cde362..b331957365d7 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1206,3 +1206,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 64fa2d8b16cf..9e8540dd0a09 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