Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3650,6 +3650,18 @@ impl<'a> Parser<'a> {
result
}

/// A CSS `||` group needs one or more of its operands. Its parser calls this
/// after it tried them: fails when no token was consumed since `start`, an
/// earlier `position()`. Lightning CSS returns a value of defaults there.
/// A browser drops the declaration.
#[inline]
pub(crate) fn expect_advanced_from(&self, start: usize) -> CssResult<()> {
if self.position() == start {
return Err(self.new_custom_error(ParserError::invalid_declaration));
}
Ok(())
}

pub(crate) fn skip_cdc_and_cdo(&mut self) {
if let Some(block_type) = self.at_start_of.take() {
consume_until_end_of_block(block_type, &mut self.input.tokenizer);
Expand Down
2 changes: 2 additions & 0 deletions src/css/properties/animation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl Animation {
let mut fill_mode: Option<AnimationFillMode> = None;
let mut timeline: Option<AnimationTimeline> = None;

let start = input.position();
loop {
if duration.is_none() {
if let Ok(value) = input.try_parse(Time::parse) {
Expand Down Expand Up @@ -119,6 +120,7 @@ impl Animation {
}
break;
}
input.expect_advanced_from(start)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Pre-existing, widened by this change: a stylesheet with .a{animation:none}.b{color:red}.a{animation:} now loses the animation:none reset that the base kept. The empty value now becomes an unparsed declaration, and StyleRule::is_duplicate (src/css/rules/style.rs:480) compares only property_id(), so the earlier .a rule is removed and the browser drops the unparsed .a{animation:}. Fix: make the duplicate-rule check not treat an unparsed declaration as equal to a parsed one of the same name, so every shorthand this PR turns into unparsed text keeps the earlier same-selector rule; the PR marks this with a cssTestTodo rather than fixing it. [also at: src/css/properties/flex.rs:163 - Minified output drops a valid flex value entirely when a later same-selector rule has an empty flex: and a different rule sits between them; main kept flex:1.; src/css/properties/masking.rs:204 - Pages that reset mask, background, transition or flex-flow in an earlier same-selector rule lose the reset in minified output when a later rule has the empty shorthand; main preserved it. src/css/properties/masking.rs:204 (and background.rs:111, transition.rs:85, flex.rs:85) makes the empty…]

Why this was flagged

Input .a{animation:none}.b{color:red}.a{animation:} through bun build or the dev server. With the diff, Animation::parse fails at src/css/properties/animation.rs:123 and the declaration is stored as an unparsed property whose property_id() is still Animation. During minify, StyleRule::is_duplicate at src/css/rules/style.rs:465-495 compares rule declarations by property_id() only, so the first .a rule is judged a duplicate of the last and removed. Output is .b{color:red}.a{animation:}; a browser drops the empty declaration, so no animation:none reaches the page. On the base branch the empty value parsed to none, so the output .b{color:red}.a{animation:none} still applied the reset. The same removal now happens for background, transition, mask, flex and flex-flow rules in that shape; the PR description and the cssTestTodo at test/js/bun/css/css.test.ts:8058 note the duplicate check but leave it unfixed.

Verification: The failed typed parse becomes Property::Unparsed carrying the original id (src/css/properties/properties_generated.rs:3888-3889). src/css/rules/style.rs:466-480 is_duplicate compares only a.property_id() != b.property_id(), so a parsed animation:none and an unparsed animation: compare equal; src/css/rules/mod.rs:850-859 then drops the earlier .a rule.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. It is the second Downsides bullet of the PR body, and the cssTestTodo row holds the expected output. I did not change is_duplicate in this PR, for these reasons.

  • main removes the earlier rule too. .a{background:red}.b{color:red}.a{background:} prints .b{color:red}.a{background:0 0} on main and .b{color:red}.a{background:} with this PR. A browser computes a transparent background for both. The output of main is right only when the value that it made from nothing equals the earlier value, as with animation:none. For .a{flex:2}.b{color:red}.a{flex:} main prints flex:1, which is also not the flex:2 of the source.
  • The check has this defect for every unparsed value of every property, on main and with this PR. .a{color:red}.b{top:0}.a{color:foo(1)} prints .b{top:0}.a{color:foo(1)}. .a{font:12px a}.b{color:red}.a{font:} prints .b{color:red}.a{font:}.
  • "An unparsed declaration is not equal to a parsed one" is not the complete rule. It keeps .a{color:red} before a later .a{color:var(--x)}. That later rule is valid and does override the first, so sheets with no defect grow. It also does not cover an earlier unparsed value: .a{background:var(--x)}.b{color:red}.a{background:} still prints .b{color:red}.a{background:}.
  • The rule must say which later values a browser can drop, for all properties, and it needs a measurement of the output change. That is a separate change in the rule minifier. Open PRs css: compare custom property names in PropertyId equality and importance in duplicate-rule detection #33684, css: fold relative colors on the scale of their function, and keep them as written when the fold is not exact #45362 and css: parse the *property hack as a declaration #46751 each edit is_duplicate.


css::Result::Ok(Animation {
name: name.unwrap_or(AnimationName::None),
Expand Down
2 changes: 2 additions & 0 deletions src/css/properties/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ impl Background {
let mut origin: Option<BackgroundOrigin> = None;
let mut clip: Option<BackgroundClip> = None;

let start = input.position();
loop {
// TODO: only allowed on the last background.
if color.is_none() {
Expand Down Expand Up @@ -107,6 +108,7 @@ impl Background {

break;
}
input.expect_advanced_from(start)?;

if clip.is_none() {
if let Some(o) = origin {
Expand Down
4 changes: 4 additions & 0 deletions src/css/properties/flex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ impl FlexFlow {
let mut direction: Option<FlexDirection> = None;
let mut wrap: Option<FlexWrap> = None;

let start = input.position();
loop {
if direction.is_none() {
if let Ok(value) = input.try_parse(FlexDirection::parse) {
Expand All @@ -81,6 +82,7 @@ impl FlexFlow {
}
break;
}
input.expect_advanced_from(start)?;

Ok(FlexFlow {
direction: direction.unwrap_or(FlexDirection::Row),
Expand Down Expand Up @@ -139,6 +141,7 @@ impl Flex {
let mut shrink: Option<CSSNumber> = None;
let mut basis: Option<LengthPercentageOrAuto> = None;

let start = input.position();
loop {
if grow.is_none() {
if let Ok(value) = input.try_parse(CSSNumberFns::parse) {
Expand All @@ -157,6 +160,7 @@ impl Flex {

break;
}
input.expect_advanced_from(start)?;

Ok(Flex {
grow: grow.unwrap_or(1.0),
Expand Down
2 changes: 2 additions & 0 deletions src/css/properties/masking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ impl Mask {
let mut composite: Option<MaskComposite> = None;
let mut mode: Option<MaskMode> = None;

let start = input.position();
loop {
if image.is_none() {
if let Ok(value) = input.try_parse(Image::parse) {
Expand Down Expand Up @@ -200,6 +201,7 @@ impl Mask {

break;
}
input.expect_advanced_from(start)?;

if clip.is_none() {
if let Some(o) = origin {
Expand Down
2 changes: 2 additions & 0 deletions src/css/properties/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl Transition {
let mut delay: Option<Time> = None;
let mut timing_function: Option<EasingFunction> = None;

let start = parser.position();
loop {
if duration.is_none() {
if let Ok(value) = parser.try_parse(Time::parse) {
Expand Down Expand Up @@ -81,6 +82,7 @@ impl Transition {

break;
}
parser.expect_advanced_from(start)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Sites whose stylesheets have a trailing comma in an animation list lose the effect in built output after upgrading; on the base branch it ran. src/css/properties/transition.rs:85 and the matching checks in animation.rs:123, masking.rs:204 and background.rs:111 reject the empty list item, so .a{animation:spin 1s,} is kept as written and every browser drops the declaration, where main printed animation:1s spin,none. Fix: decide the repair explicitly: either keep the empty-item repair for list shorthands (fail only when the whole value is empty) or emit a warning for the dropped declaration so the regression is visible.

Why this was flagged

If any application stylesheet contains a trailing or doubled comma in animation, transition, mask or background (a common typo and a common template-engine artifact), the parse of the empty item now fails at src/css/properties/transition.rs:85 (and animation.rs:123, masking.rs:204, background.rs:111) and Property::parse falls back to Unparsed at src/css/properties/properties_generated.rs:3888. The output is the invalid source text, which browsers discard. On the base branch the same input was printed as animation:1s spin,none or transition:color 1s,all, which browsers accept, so the animation ran and the transition applied in deployed builds. The author's 70,034-file npm sample measures library CSS, not application CSS, so the affected population is not ruled out; the PR itself lists this as a downside a maintainer must decide, and no test or warning marks the silent loss. The dismissal took the author's framing as settled rather than sizing the user-visible regression.

Verification: Acknowledged in diff: the PR description's "Downsides" and the new test block pin it. Now src/css/properties/animation.rs:123 returns Err(invalid_declaration) for the empty item after a trailing comma, and Property::parse prints the source text. On the base branch animation:spin 1s, came out as the working 1s spin,none; now it reaches the browser as-is and is dropped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first Downsides bullet of the PR body. I did not change the code, for these reasons.

The repair is a side effect of the defect, and it helps only some inputs. Release builds of main and of this PR, with Chrome 153:

source main prints effect of the output of main
animation:spin 1s, animation:1s spin,none the animation runs
background:,red background:0 0,red paints red
background:red, background:0 0,0 0 paints nothing, the red is lost
transition:color 1s, transition:color 1s,all no transition starts
  • If the list parsers fail only for an empty whole value, transition:opacity 2s;transition:color 1s, still prints transition:color 1s,all. The valid opacity 2s is lost. That is the list item case of the Problem section.
  • The longhands do not have the repair. animation-name:spin, and transition-property:color, print as written on main.
  • A warning: no value that the typed parser rejects gives a warning today (font:, color:banana, animation-name:spin,). A warning for these four shorthands only is a special case. A warning for every known property is a new diagnostic, and css: log parser warnings with the Source and a byte position #45787 changes how the parser logs warnings.

A maintainer decides between the output of 1.4.x and the strict output. The PR body says so.


Ok(Self {
property: property.unwrap_or(PropertyId::All),
Expand Down
145 changes: 145 additions & 0 deletions test/js/bun/css/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7965,6 +7965,151 @@ describe("css tests", () => {
});

describe("edge cases", () => {
// The grammar of these six shorthands is a `||` group, which needs one or
// more of its components. An empty value and an empty item of a
// comma-separated list are invalid, and a browser drops the declaration. It
// stays as written and does not replace the declaration before it in the
// same block.
describe("empty value of a shorthand whose components are all optional", () => {
minify_test(".a{background:red;background:}", ".a{background:red;background:}");
minify_test(".b{animation:spin 1s;animation:}", ".b{animation:1s spin;animation:}");
minify_test(".c{transition:color 1s;transition:}", ".c{transition:color 1s;transition:}");
minify_test(".d{flex-flow:column;flex-flow:}", ".d{flex-flow:column;flex-flow:}");
minify_test(".e{mask:url(#m);mask:}", ".e{mask:url(#m);mask:}");
minify_test(".f{flex:2;flex:}", ".f{flex:2;flex:}");
// `font` and `border` need a component, so they never parsed an empty value.
minify_test(".g{font:12px a;font:}", ".g{font:12px a;font:}");
minify_test(".h{border:1px solid red;border:}", ".h{border:1px solid red;border:}");
cssTest(".a { background: red; background: }", ".a { background: red; background: ; }");

// Each name with a valid value, in the form that the minifier prints.
const single = {
"flex-flow": "column",
"-webkit-flex-flow": "column",
"-ms-flex-flow": "column",
"flex": "2",
"-webkit-flex": "2",
"-ms-flex": "2",
};
const commaSeparated = {
"background": "red",
"transition": "color 1s",
"-webkit-transition": "color 1s",
"-moz-transition": "color 1s",
"-ms-transition": "color 1s",
"animation": "1s spin",
"-webkit-animation": "1s spin",
"-moz-animation": "1s spin",
"-ms-animation": "1s spin",
"-o-animation": "1s spin",
"mask": "url(#m)",
"-webkit-mask": "url(#m)",
};
const emptyValues = ["", " ", "\n\t", "/**/", " /* c */ ", "!important", " !important"];
const emptyItems = (valid: string) => [
`${valid},`,
`,${valid}`,
`${valid},,${valid}`,
",",
`${valid}, `,
`${valid},/**/`,
`${valid},!important`,
];
// Which blanks and comments an unparsed value keeps is not the subject here.
const withoutBlanks = (css: string) => css.replace(/\s+|\/\*.*?\*\//g, "");

function keepsBothDeclarations(name: string, valid: string, values: string[]) {
test(name, () => {
const sources = values.map(value => `.a{${name}:${valid};${name}:${value}}`);
expect(sources.map(source => withoutBlanks(minify_test_with_options(source, "")))).toEqual(
sources.map(withoutBlanks),
);
});
}
for (const [name, valid] of Object.entries(single)) keepsBothDeclarations(name, valid, emptyValues);
for (const [name, valid] of Object.entries(commaSeparated)) {
keepsBothDeclarations(name, valid, [...emptyValues, ...emptyItems(valid)]);
}

// The invalid declaration does not merge with the declarations around it.
minify_test(".a{transition:opacity 2s;transition:color 1s,}", ".a{transition:opacity 2s;transition:color 1s,}");
minify_test(".a{background-color:red;background:}", ".a{background-color:red;background:}");
minify_test(".a{flex-grow:2;flex:}", ".a{flex-grow:2;flex:}");
minify_test(".a{flex-direction:column;flex-flow:}", ".a{flex-direction:column;flex-flow:}");
minify_test(
".a{transition:color 1s;transition:;transition-duration:2s}",
".a{transition:color 1s;transition:;transition-duration:2s}",
);
minify_test(".a{background:;background:red}", ".a{background:;background:red}");
minify_test(
".a{background:red;background:!important;background:blue}",
".a{background:#00f;background:!important}",
);

// Two rules with the same selector. Adjacent rules merge into one block, and an at-rule
// between them keeps both.
minify_test(".a{background:red}.a{background:}", ".a{background:red;background:}");
minify_test(
".a{background:red}@media print{.q{top:0}}.a{background:}",
".a{background:red}@media print{.q{top:0}}.a{background:}",
);
// With a style rule between them, the duplicate-rule check (`StyleRule::is_duplicate`)
// removes the first rule: it compares the property names only, for every unparsed value.
cssTestTodo(
".a { background: red } .b { color: red } .a { background: }",
".a { background: red; } .b { color: red; } .a { background: ; }",
);

// Declarations that the shorthand handlers do not see.
minify_test(
"@keyframes k{from{background:red;background:}to{flex:2;flex:}}",
"@keyframes k{0%{background:red;background:}to{flex:2;flex:}}",
);
minify_test("@container style(background:){.a{color:red}}", "@container style(background:){.a{color:red}}");
minify_test(
"@container style(transition:color 1s,){.a{color:red}}",
"@container style(transition:color 1s,){.a{color:red}}",
);

// One component is enough.
minify_test(".a{background:none}", ".a{background:0 0}");
minify_test(".a{flex:0%}", ".a{flex:1}");
minify_test(".a{flex-flow:nowrap}", ".a{flex-flow:row}");
minify_test(".a{transition:0s}", ".a{transition:all}");
minify_test(".a{animation:0s}", ".a{animation:none}");
minify_test(".a{mask:match-source}", ".a{mask:none}");
// An empty value is valid for a custom property.
minify_test(".a{--x:1;--x:}", ".a{--x:1;--x:}");

// The names come from the typed property table in the source, so the check also runs for a
// property that gets a typed parser later. Only an unparsed value keeps the blank, so a
// typed parser whose empty value prints as nothing fails too.
test("no known property parses an empty value or a lone comma", async () => {
const rust = await Bun.file(join(__dirname, "../../../../src/css/properties/properties_generated.rs")).text();
const start = rust.search(/static KNOWN\b[^{]*\{/);
const table = rust.slice(start, rust.indexOf("};", start));
const names: string[] = [];
for (const [, name, prefixes] of table.matchAll(/b"([a-z-]+)" => \(([^,]+),/g)) {
names.push(name);
for (const [, prefix] of prefixes.matchAll(/VendorPrefix::(WEBKIT|MOZ|MS|O)\b/g)) {
names.push(`-${prefix.toLowerCase()}-${name}`);
}
}
expect(names.length).toBeGreaterThan(350);

const parsed: string[] = [];
for (const name of names) {
// `composes:` is a parse error.
if (name === "composes") continue;
for (const source of [`.x{${name}: }`, `.x{${name}:,}`]) {
const output = minify_test_with_options(source, "");
if (output !== source) parsed.push(`${source} -> ${output}`);
}
}
expect(parsed).toEqual([]);
});
});

describe("invalid gradient", () => {
cssTest(
`
Expand Down
Loading