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.
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 likepostcss-custom-properties), Lightning CSS minification reverses their order for three specific properties:position,direction, andunicode-bidi.This breaks the CSS cascade - the static fallback ends up after the
var()declaration, overriding it.Reproduction
Input:
Output (minified):
Expected - declaration order should be preserved:
Standalone reproduction (no Vite/PostCSS needed):
These properties are NOT affected (order is preserved correctly):
Version: lightningcss@1.32.0
Real-world impact
postcss-preset-env(viapostcss-custom-properties) expandsposition: var(--x, static)into:This is correct CSS - the
var()version wins in supporting browsers, andstaticis 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 toDeclarationHandler::handle_property(). If a handler claims a declaration (returnstrue), it's consumed. If no handler claims it, it's pushed to the output list immediately:After all declarations are processed,
handler.finalize()is called, which emits any buffered values.The problem:
PositionHandleronly matchesProperty::Position(...). Whenposition: var(--x, static)arrives asProperty::Unparsed(...), the handler doesn't recognize it and returnsfalse. Thevar()declaration gets pushed to output immediately. Thenfinalize()emits the bufferedposition: staticafter it - reversing the order.The same issue affects
directionandunicode-bidi, which are buffered asOptionfields inDeclarationHandler::handle_all()(src/declaration.rs:645–672) and emitted infinalize()at line 694.Every other handler is safe because they include an explicit
Unparsedmatch arm that flushes before pushing. For example,OverflowHandler(src/properties/overflow.rs:96):The
shorthand_handler!macro also auto-generates this pattern (src/macros.rs:248), so all macro-based handlers are safe.Suggested fix
For
PositionHandlerinsrc/properties/position.rs- add anUnparsedarm matching the pattern used by every other handler:For
directionandunicode-bidiinsrc/declaration.rs- addUnparsedhandling inhandle_all()that flushes the bufferedOptionbefore pushing the unparsed property.Happy to submit a PR if that's helpful.