diff --git a/CHANGELOG.md b/CHANGELOG.md index 95603dd65a98..eda8c5b631fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/collapseDuplicateDeclarations.js b/src/lib/collapseDuplicateDeclarations.js index 9164f822ea78..d310d58b88b6 100644 --- a/src/lib/collapseDuplicateDeclarations.js +++ b/src/lib/collapseDuplicateDeclarations.js @@ -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 @@ -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 +} diff --git a/tests/apply.test.js b/tests/apply.test.js index c74bb2875f5d..59807a633129 100644 --- a/tests/apply.test.js +++ b/tests/apply.test.js @@ -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; } diff --git a/tests/collapse-duplicate-declarations.test.js b/tests/collapse-duplicate-declarations.test.js new file mode 100644 index 000000000000..db165186469b --- /dev/null +++ b/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`
` }], + 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`
` }], + 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`
` }], + 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`
` }], + 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`
` }], + 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`
` }], + 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; + } + `) + }) +})