Collapse more utilities by expanding their declarations (#19842)

This PR adds more declaration expansions such that we can collapse more
utilities.

While testing #19837 I noticed that in my tests some utilities weren't
canonicalized correctly. As part of that PR, we check for
`parsedCandidate.value === null`, which means that a functional utility
without a value is skipped. We do have utilities like that such as
`border` (which is equivalent to `border-1`). But while testing, I
noticed that `border-x border-y` should collapse to `border` but they
didn't. This PR fixes that.

By expanding these properties to their long-form physical properties
(instead of the shorter logical properties) we make the signatures of
utilities a bit bigger, but also more correct such that we can collapse
the physical form into logical utilities.

To make this more concrete, this PR allows for the following
canonicalizations now:

| Input | Output |
| --- | --- |
| `border-t-123 border-r-123 border-b-123 border-l-123` | `border-123` |
| `border-t-1 border-r-1 border-b-1 border-l-1` | `border` |
| `border-t-123 border-b-123` | `border-y-123` |
| `border-l-123 border-r-123` | `border-x-123` |
| `border-t-red-500 border-r-red-500 border-b-red-500 border-l-red-500`
| `border-red-500` |
| `border-t-red-500 border-b-red-500` | `border-y-red-500` |
| `border-l-red-500 border-r-red-500` | `border-x-red-500` |
| `scroll-mt-123 scroll-mr-123 scroll-mb-123 scroll-ml-123` |
`scroll-m-123` |
| `scroll-mt-123 scroll-mb-123` | `scroll-my-123` |
| `scroll-ml-123 scroll-mr-123` | `scroll-mx-123` |
| `scroll-pt-123 scroll-pr-123 scroll-pb-123 scroll-pl-123` |
`scroll-p-123` |
| `scroll-pt-123 scroll-pb-123` | `scroll-py-123` |
| `scroll-pl-123 scroll-pr-123` | `scroll-px-123` |
| `overflow-x-hidden overflow-y-hidden` | `overflow-hidden` |
| `overscroll-x-contain overscroll-y-contain` | `overscroll-contain` |

## Test plan

1. Existing tests pass
2. Added a few more tests to verify that these canonicalizations work
This commit is contained in:
Robin Malfait 2026-03-23 15:33:44 +01:00 • committed by GitHub
parent b55d96002c
commit 28d526859d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 513 additions and 3 deletions

View file

@ -16,7 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve canonicalizations for `tracking-*` utilities ([#19827](https://github.com/tailwindlabs/tailwindcss/pull/19827))
- Fix crash due to invalid characters in candidate ([#19829](https://github.com/tailwindlabs/tailwindcss/pull/19829))
- Ensure query params in imports are considered unique resources when using `@tailwindcss/webpack` ([#19723](https://github.com/tailwindlabs/tailwindcss/pull/19723))
- Collapse arbitrary values into shorthand utilities during canonicalization ([#19837](https://github.com/tailwindlabs/tailwindcss/pull/19837))
- Canonicalization: collapse arbitrary values into shorthand utilities (e.g. `px-[1.2rem] py-[1.2rem]` → `p-[1.2rem]`) ([#19837](https://github.com/tailwindlabs/tailwindcss/pull/19837))
- Canonicalization: collapse `border-{t,b}-*` into `border-y-*`, `border-{l,r}-*` into `border-x-*`, and `border-{t,r,b,l}-*` into `border-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `scroll-m{t,b}-*` into `scroll-my-*`, `scroll-m{l,r}-*` into `scroll-mx-*`, and `scroll-m{t,r,b,l}-*` into `scroll-m-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `scroll-p{t,b}-*` into `scroll-py-*`, `scroll-p{l,r}-*` into `scroll-px-*`, and `scroll-p{t,r,b,l}-*` into `scroll-p-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `overflow-{x,y}-*` into `overflow-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `overscroll-{x,y}-*` into `overscroll-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
## [4.2.2] - 2026-03-18

View file

@ -1044,9 +1044,21 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
test.each([
// 4 to 1
['mt-1 mr-1 mb-1 ml-1', 'm-1'],
['border-t-123 border-r-123 border-b-123 border-l-123', 'border-123'],
['border-t-1 border-r-1 border-b-1 border-l-1', 'border'], // `border` is shorter than `border-1`
['border-t-red-500 border-r-red-500 border-b-red-500 border-l-red-500', 'border-red-500'],
['scroll-mt-1 scroll-mr-1 scroll-mb-1 scroll-ml-1', 'scroll-m-1'],
['scroll-pt-1 scroll-pr-1 scroll-pb-1 scroll-pl-1', 'scroll-p-1'],
// 2 to 1
['mt-1 mb-1', 'my-1'],
['border-t-123 border-b-123', 'border-y-123'],
['border-t-1 border-b-1', 'border-y'], // `border-y` is shorter than `border-y-1`
['border-t-red-500 border-b-red-500', 'border-y-red-500'],
['scroll-mt-1 scroll-mb-1', 'scroll-my-1'],
['scroll-pt-1 scroll-pb-1', 'scroll-py-1'],
['overflow-x-hidden overflow-y-hidden', 'overflow-hidden'],
['overscroll-x-contain overscroll-y-contain', 'overscroll-contain'],
// Different order as above
['mb-1 mt-1', 'my-1'],

View file

@ -23,7 +23,7 @@ function expand(input: string, options: SignatureFeatures): string {
describe('expand declarations', () => {
let options = SignatureFeatures.ExpandProperties
test('expand to 4 properties', () => {
test('inset', () => {
let input = css`
.one {
inset: 10px;
@ -71,7 +71,7 @@ describe('expand declarations', () => {
`)
})
test('expand to 2 properties', () => {
test('gap', () => {
let input = css`
.one {
gap: 10px;
@ -95,6 +95,294 @@ describe('expand declarations', () => {
`)
})
test('border-width', () => {
let input = css`
.one {
border-width: 1px;
}
.two {
border-width: 1px 2px;
}
.three {
border-width: 1px 2px 3px;
}
.four {
border-width: 1px 2px 3px 4px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
border-top-width: 1px;
border-right-width: 1px;
border-bottom-width: 1px;
border-left-width: 1px;
}
.two {
border-top-width: 1px;
border-right-width: 2px;
border-bottom-width: 1px;
border-left-width: 2px;
}
.three {
border-top-width: 1px;
border-right-width: 2px;
border-bottom-width: 3px;
border-left-width: 2px;
}
.four {
border-top-width: 1px;
border-right-width: 2px;
border-bottom-width: 3px;
border-left-width: 4px;
}
"
`)
})
test('border-style', () => {
let input = css`
.one {
border-style: solid;
}
.two {
border-style: solid dashed;
}
.three {
border-style: solid dashed dotted;
}
.four {
border-style: solid dashed dotted double;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
border-top-style: solid;
border-right-style: solid;
border-bottom-style: solid;
border-left-style: solid;
}
.two {
border-top-style: solid;
border-right-style: dashed;
border-bottom-style: solid;
border-left-style: dashed;
}
.three {
border-top-style: solid;
border-right-style: dashed;
border-bottom-style: dotted;
border-left-style: dashed;
}
.four {
border-top-style: solid;
border-right-style: dashed;
border-bottom-style: dotted;
border-left-style: double;
}
"
`)
})
test('border-color', () => {
let input = css`
.one {
border-color: red;
}
.two {
border-color: red green;
}
.three {
border-color: red green blue;
}
.four {
border-color: red green blue black;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
border-top-color: red;
border-right-color: red;
border-bottom-color: red;
border-left-color: red;
}
.two {
border-top-color: red;
border-right-color: green;
border-bottom-color: red;
border-left-color: green;
}
.three {
border-top-color: red;
border-right-color: green;
border-bottom-color: blue;
border-left-color: green;
}
.four {
border-top-color: red;
border-right-color: green;
border-bottom-color: blue;
border-left-color: black;
}
"
`)
})
test('scroll-margin', () => {
let input = css`
.one {
scroll-margin: 1px;
}
.two {
scroll-margin: 1px 2px;
}
.three {
scroll-margin: 1px 2px 3px;
}
.four {
scroll-margin: 1px 2px 3px 4px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
scroll-margin-top: 1px;
scroll-margin-right: 1px;
scroll-margin-bottom: 1px;
scroll-margin-left: 1px;
}
.two {
scroll-margin-top: 1px;
scroll-margin-right: 2px;
scroll-margin-bottom: 1px;
scroll-margin-left: 2px;
}
.three {
scroll-margin-top: 1px;
scroll-margin-right: 2px;
scroll-margin-bottom: 3px;
scroll-margin-left: 2px;
}
.four {
scroll-margin-top: 1px;
scroll-margin-right: 2px;
scroll-margin-bottom: 3px;
scroll-margin-left: 4px;
}
"
`)
})
test('scroll-padding', () => {
let input = css`
.one {
scroll-padding: 1px;
}
.two {
scroll-padding: 1px 2px;
}
.three {
scroll-padding: 1px 2px 3px;
}
.four {
scroll-padding: 1px 2px 3px 4px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
scroll-padding-top: 1px;
scroll-padding-right: 1px;
scroll-padding-bottom: 1px;
scroll-padding-left: 1px;
}
.two {
scroll-padding-top: 1px;
scroll-padding-right: 2px;
scroll-padding-bottom: 1px;
scroll-padding-left: 2px;
}
.three {
scroll-padding-top: 1px;
scroll-padding-right: 2px;
scroll-padding-bottom: 3px;
scroll-padding-left: 2px;
}
.four {
scroll-padding-top: 1px;
scroll-padding-right: 2px;
scroll-padding-bottom: 3px;
scroll-padding-left: 4px;
}
"
`)
})
test('overflow', () => {
let input = css`
.one {
overflow: clip;
}
.two {
overflow: hidden visible;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
overflow-x: clip;
overflow-y: clip;
}
.two {
overflow-x: hidden;
overflow-y: visible;
}
"
`)
})
test('overscroll-behavior', () => {
let input = css`
.one {
overscroll-behavior: none;
}
.two {
overscroll-behavior: auto contain;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".one {
overscroll-behavior-x: none;
overscroll-behavior-y: none;
}
.two {
overscroll-behavior-x: auto;
overscroll-behavior-y: contain;
}
"
`)
})
test('expansion with `!important`', () => {
let input = css`
.one {
@ -147,6 +435,22 @@ describe('expand declarations', () => {
describe('expand logical properties', () => {
let options = SignatureFeatures.ExpandProperties | SignatureFeatures.LogicalToPhysical
test('margin-inline', () => {
let input = css`
.example {
margin-inline: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
margin-left: 10px;
margin-right: 20px;
}
"
`)
})
test('margin-block', () => {
let input = css`
.example {
@ -162,4 +466,164 @@ describe('expand logical properties', () => {
"
`)
})
test('padding-inline', () => {
let input = css`
.example {
padding-inline: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
padding-left: 10px;
padding-right: 20px;
}
"
`)
})
test('padding-block', () => {
let input = css`
.example {
padding-block: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
padding-top: 10px;
padding-bottom: 20px;
}
"
`)
})
test('scroll-margin-inline', () => {
let input = css`
.example {
scroll-margin-inline: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
scroll-margin-left: 10px;
scroll-margin-right: 20px;
}
"
`)
})
test('scroll-margin-block', () => {
let input = css`
.example {
scroll-margin-block: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
scroll-margin-top: 10px;
scroll-margin-bottom: 20px;
}
"
`)
})
test('scroll-padding-inline', () => {
let input = css`
.example {
scroll-padding-inline: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
scroll-padding-left: 10px;
scroll-padding-right: 20px;
}
"
`)
})
test('scroll-padding-block', () => {
let input = css`
.example {
scroll-padding-block: 10px 20px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
scroll-padding-top: 10px;
scroll-padding-bottom: 20px;
}
"
`)
})
test('border-inline-width', () => {
let input = css`
.example {
border-inline-width: 1px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
border-left-width: 1px;
border-right-width: 1px;
}
"
`)
})
test('border-block-width', () => {
let input = css`
.example {
border-block-width: 1px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
border-bottom-width: 1px;
border-top-width: 1px;
}
"
`)
})
test('border-inline-style', () => {
let input = css`
.example {
border-inline-style: 1px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
border-left-style: 1px;
border-right-style: 1px;
}
"
`)
})
test('border-block-style', () => {
let input = css`
.example {
border-block-style: 1px;
}
`
expect(expand(input, options)).toMatchInlineSnapshot(`
".example {
border-bottom-style: 1px;
border-top-style: 1px;
}
"
`)
})
})

View file

@ -35,7 +35,32 @@ let VARIADIC_EXPANSION_MAP: Record<string, Record<number, [prop: string, index:
inset: createBareQuad(),
margin: createPrefixedQuad('margin'),
padding: createPrefixedQuad('padding'),
'scroll-margin': createPrefixedQuad('scroll-margin'),
'scroll-padding': createPrefixedQuad('scroll-padding'),
'border-width': createPrefixedQuad(
'border',
'top-width',
'right-width',
'bottom-width',
'left-width',
),
'border-style': createPrefixedQuad(
'border',
'top-style',
'right-style',
'bottom-style',
'left-style',
),
'border-color': createPrefixedQuad(
'border',
'top-color',
'right-color',
'bottom-color',
'left-color',
),
gap: createPair('row-gap', 'column-gap'),
overflow: createPair('overflow-x', 'overflow-y'),
'overscroll-behavior': createPair('overscroll-behavior-x', 'overscroll-behavior-y'),
}
// Depending on the length of the value, map to different properties
@ -49,6 +74,10 @@ let VARIADIC_LOGICAL_EXPANSION_MAP: Record<
'margin-inline': createPair('margin-left', 'margin-right'),
'padding-block': createPair('padding-top', 'padding-bottom'),
'padding-inline': createPair('padding-left', 'padding-right'),
'scroll-margin-block': createPair('scroll-margin-top', 'scroll-margin-bottom'),
'scroll-margin-inline': createPair('scroll-margin-left', 'scroll-margin-right'),
'scroll-padding-block': createPair('scroll-padding-top', 'scroll-padding-bottom'),
'scroll-padding-inline': createPair('scroll-padding-left', 'scroll-padding-right'),
}
// The entire value is mapped to each property