Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve collapsing of duplicate declarations #6856

Merged
merged 2 commits into from Jan 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure we can use `<` and `>` characters in modifiers ([#6851](https://github.com/tailwindlabs/tailwindcss/pull/6851))
- Validate `theme()` works in arbitrary values ([#6852](https://github.com/tailwindlabs/tailwindcss/pull/6852))
- Properly detect `theme()` value usage in arbitrary properties ([#6854](https://github.com/tailwindlabs/tailwindcss/pull/6854))
- Improve collapsing of duplicate declarations ([#6856](https://github.com/tailwindlabs/tailwindcss/pull/6856))

## [3.0.8] - 2021-12-28

Expand Down
67 changes: 66 additions & 1 deletion src/lib/collapseDuplicateDeclarations.js
Expand Up @@ -3,6 +3,7 @@ export default function collapseDuplicateDeclarations() {
root.walkRules((node) => {
let seen = new Map()
let droppable = new Set([])
let byProperty = new Map()

node.walkDecls((decl) => {
// This could happen if we have nested selectors. In that case the
Expand All @@ -14,15 +15,79 @@ export default function collapseDuplicateDeclarations() {
}

if (seen.has(decl.prop)) {
droppable.add(seen.get(decl.prop))
// Exact same value as what we have seen so far
if (seen.get(decl.prop).value === decl.value) {
// Keep the last one, drop the one we've seen so far
droppable.add(seen.get(decl.prop))
// Override the existing one with the new value. This is necessary
// so that if we happen to have more than one declaration with the
// same value, that we keep removing the previous one. Otherwise we
// will only remove the *first* one.
seen.set(decl.prop, decl)
return
}

// Not the same value, so we need to check if we can merge it so
// let's collect it first.
if (!byProperty.has(decl.prop)) {
byProperty.set(decl.prop, new Set())
}

byProperty.get(decl.prop).add(seen.get(decl.prop))
byProperty.get(decl.prop).add(decl)
}

seen.set(decl.prop, decl)
})

// Drop all the duplicate declarations with the exact same value we've
// already seen so far.
for (let decl of droppable) {
decl.remove()
}

// Analyze the declarations based on its unit, drop all the declarations
// with the same unit but the last one in the list.
for (let declarations of byProperty.values()) {
let byUnit = new Map()

for (let decl of declarations) {
let unit = resolveUnit(decl.value)
if (unit === null) {
// We don't have a unit, so should never try and collapse this
// value. This is because we can't know how to do it in a correct
// way (e.g.: overrides for older browsers)
continue
}

if (!byUnit.has(unit)) {
byUnit.set(unit, new Set())
}

byUnit.get(unit).add(decl)
}

for (let declarations of byUnit.values()) {
// Get all but the last one
let removableDeclarations = Array.from(declarations).slice(0, -1)

for (let decl of removableDeclarations) {
decl.remove()
}
}
}
})
}
}

let UNITLESS_NUMBER = Symbol('unitless-number')

function resolveUnit(input) {
let result = /^-?\d*.?\d+([\w%]+)?$/g.exec(input)

if (result) {
return result[1] ?? UNITLESS_NUMBER
}

return null
}
1 change: 1 addition & 0 deletions tests/apply.test.js
Expand Up @@ -351,6 +351,7 @@ test('@applying classes from outside a @layer respects the source order', async
await run(input, config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
.baz {
text-decoration-line: underline;
text-decoration-line: none;
}

Expand Down
175 changes: 175 additions & 0 deletions tests/collapse-duplicate-declarations.test.js
@@ -0,0 +1,175 @@
import { run, css, html } from './util/run'

it('should collapse duplicate declarations with the same units (px)', () => {
let config = {
content: [{ raw: html`<div class="example"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}

let input = css`
@tailwind utilities;

@layer utilities {
.example {
height: 100px;
height: 200px;
}
}
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.example {
height: 200px;
}
`)
})
})

it('should collapse duplicate declarations with the same units (no unit)', () => {
let config = {
content: [{ raw: html`<div class="example"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}

let input = css`
@tailwind utilities;

@layer utilities {
.example {
line-height: 3;
line-height: 2;
}
}
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.example {
line-height: 2;
}
`)
})
})

it('should not collapse duplicate declarations with the different units', () => {
let config = {
content: [{ raw: html`<div class="example"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}

let input = css`
@tailwind utilities;

@layer utilities {
.example {
height: 100px;
height: 50%;
}
}
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.example {
height: 100px;
height: 50%;
}
`)
})
})

it('should collapse the duplicate declarations with the same unit, but leave the ones with different units', () => {
let config = {
content: [{ raw: html`<div class="example"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}

let input = css`
@tailwind utilities;

@layer utilities {
.example {
height: 100px;
height: 50%;
height: 20vh;
height: 200px;
height: 100%;
height: 30vh;
}
}
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.example {
height: 200px;
height: 100%;
height: 30vh;
}
`)
})
})

it('should collapse the duplicate declarations with the exact same value', () => {
let config = {
content: [{ raw: html`<div class="example"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}

let input = css`
@tailwind utilities;

@layer utilities {
.example {
height: var(--value);
color: blue;
height: var(--value);
}
}
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.example {
color: blue;
height: var(--value);
}
`)
})
})

it('should work on a real world example', () => {
let config = {
content: [{ raw: html`<div class="h-available"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}

let input = css`
@tailwind utilities;

@layer utilities {
.h-available {
height: 100%;
height: 100vh;
height: -webkit-fill-available;
}
}
`

return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.h-available {
height: 100%;
height: 100vh;
height: -webkit-fill-available;
}
`)
})
})