Skip to content

Minification reorders duplicate declarations for position, direction, and unicode-bidi when one uses var() #1225

@maciejbak89

Description

@maciejbak89

Bug description

When a rule contains a parsed declaration followed by a var()-based duplicate of the same property (a common pattern emitted by PostCSS tools like postcss-custom-properties), Lightning CSS minification reverses their order for three specific properties: position, direction, and unicode-bidi.

This breaks the CSS cascade - the static fallback ends up after the var() declaration, overriding it.

Reproduction

Input:

.x { position: static; position: var(--x, static) }
.y { direction: ltr; direction: var(--x, ltr) }
.z { unicode-bidi: normal; unicode-bidi: var(--x, normal) }

Output (minified):

.x{position:var(--x,static);position:static}
.y{direction:var(--x,ltr);direction:ltr}
.z{unicode-bidi:var(--x,normal);unicode-bidi:normal}

Expected - declaration order should be preserved:

.x{position:static;position:var(--x,static)}
.y{direction:ltr;direction:var(--x,ltr)}
.z{unicode-bidi:normal;unicode-bidi:var(--x,normal)}

Standalone reproduction (no Vite/PostCSS needed):

const css = require('lightningcss');

const result = css.transform({
  filename: 'test.css',
  code: Buffer.from('.x{position:static;position:var(--x,static)}'),
  minify: true,
});

console.log(result.code.toString());
// Actual:   .x{position:var(--x,static);position:static}
// Expected: .x{position:static;position:var(--x,static)}

These properties are NOT affected (order is preserved correctly):

.x { display: block; display: var(--x, block) }
.x { overflow: visible; overflow: var(--x, visible) }
.x { width: auto; width: var(--x, auto) }
.x { z-index: auto; z-index: var(--x, auto) }
.x { top: auto; top: var(--x, auto) }
.x { flex-direction: column; flex-direction: var(--x, column) }

Version: lightningcss@1.32.0

Real-world impact

postcss-preset-env (via postcss-custom-properties) expands position: var(--x, static) into:

position: static;
position: var(--x, static);

This is correct CSS - the var() version wins in supporting browsers, and static is the fallback. When Lightning CSS processes this output (e.g., via Vite), it reverses the order, causing the fallback to always win and the custom property to be ignored.

Root cause

I traced through the source and identified the exact issue.

In DeclarationBlock::minify() (src/declaration.rs:244–254), declarations are iterated in order. Each is passed to DeclarationHandler::handle_property(). If a handler claims a declaration (returns true), it's consumed. If no handler claims it, it's pushed to the output list immediately:

let handled = $handler.handle_property(decl, context);
if !handled {
    $handler.decls.push(decl.clone());
}

After all declarations are processed, handler.finalize() is called, which emits any buffered values.

The problem: PositionHandler only matches Property::Position(...). When position: var(--x, static) arrives as Property::Unparsed(...), the handler doesn't recognize it and returns false. The var() declaration gets pushed to output immediately. Then finalize() emits the buffered position: static after it - reversing the order.

The same issue affects direction and unicode-bidi, which are buffered as Option fields in DeclarationHandler::handle_all() (src/declaration.rs:645–672) and emitted in finalize() at line 694.

Every other handler is safe because they include an explicit Unparsed match arm that flushes before pushing. For example, OverflowHandler (src/properties/overflow.rs:96):

Unparsed(val) if matches!(val.property_id, PropertyId::OverflowX | PropertyId::OverflowY | PropertyId::Overflow) => {
    self.finalize(dest, context);   // flush buffered value first
    dest.push(property.clone());    // then push var() version
}

The shorthand_handler! macro also auto-generates this pattern (src/macros.rs:248), so all macro-based handlers are safe.

Suggested fix

For PositionHandler in src/properties/position.rs - add an Unparsed arm matching the pattern used by every other handler:

if matches!(property, Property::Unparsed(val) if val.property_id == PropertyId::Position) {
    self.finalize(dest, context);
    dest.push(property.clone());
    return true;
}

For direction and unicode-bidi in src/declaration.rs - add Unparsed handling in handle_all() that flushes the buffered Option before pushing the unparsed property.

Happy to submit a PR if that's helpful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions