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
17 changes: 13 additions & 4 deletions docs/bundler/css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -644,15 +644,15 @@ Media query range syntax expresses breakpoints with comparison operators (`<`, `
}
}

/* Exclusive range using < and > */
@media (width > 320px) and (width < 768px) {
/* Exclusive bound using < */
@media (width < 768px) {
.mobile-only {
display: block;
}
}
```

Bun's CSS bundler converts range queries to traditional media query syntax for compatibility with all browsers:
When a target browser has no range syntax (Chrome before 104, Safari before 16.4), Bun's CSS bundler converts range queries to traditional media query syntax:

```css title="styles.css" icon="file-code"
/* Converted to traditional min/max syntax */
Expand All @@ -668,13 +668,22 @@ Bun's CSS bundler converts range queries to traditional media query syntax for c
}
}

@media (min-width: 321px) and (max-width: 767px) {
/* Exclusive bound: "not at least 768px" */
@media not all and (min-width: 768px) {
.mobile-only {
display: block;
}
}
```

The `min-` and `max-` prefixes are inclusive. When a query is one exclusive bound (`<` or `>`), Bun writes it as the negation of the opposite inclusive bound. The bound stays exclusive, and Bun adds nothing to its value. Two kinds of bound have another form. An integer bound moves to the next integer: `(color > 2)` becomes `(min-color: 3)`. A `resolution` bound moves by a small step.

Browsers without range syntax negate only a whole query. When a query combines an exclusive bound with a media type or another condition, Bun moves a length by `.02px` instead. For example, `screen and (width < 768px)` becomes `screen and (max-width: 767.98px)`. This form is an approximation:

- A viewport width between `767.98px` and `768px` is on the wrong side of the bound.
- Safari 14 reads a media length as an integer, so `screen and (width > 768px)` also applies at `768px` there.
- A length of `10000px` or more and an `env()` value get no step.

### Shorthands

CSS has introduced several shorthand properties that combine multiple longhand properties.
Expand Down
164 changes: 151 additions & 13 deletions src/css/media_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,17 @@ impl MediaList {
}
dest.write_comma_separated(&self.media_queries, |d, q| q.to_css(d))
}

/// `to_css` with `MediaQuery::to_css_as_written` for each query.
pub(crate) fn to_css_as_written(
&self,
dest: &mut Printer,
) -> core::result::Result<(), PrintErr> {
if self.media_queries.is_empty() {
return dest.write_str("not all");
}
dest.write_comma_separated(&self.media_queries, |d, q| q.to_css_as_written(d))
}
}

impl MediaQuery {
Expand All @@ -696,6 +707,16 @@ impl MediaQuery {
}

pub fn to_css(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> {
if let Some(lone) = self.lone_feature()
&& lone.to_css_without_range_syntax(dest)?
{
return Ok(());
}
self.to_css_as_written(dest)
}

/// `to_css` without `LoneFeature::to_css_without_range_syntax`.
fn to_css_as_written(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> {
if let Some(qual) = self.qualifier {
qual.to_css(dest)?;
dest.write_char(b' ')?;
Expand Down Expand Up @@ -1074,23 +1095,26 @@ impl MediaFeatureValue {
}
}

/// Adjust by `other` for strict-inequality → min/max
/// boundary lowering. Consumes `self`.
fn add_f32(self, other: f32) -> MediaFeatureValue {
/// Steps past `self` for a strict bound in a `min-`/`max-` feature: `sign` is 1.0 for `>`, -1.0 for `<`.
fn step(self, sign: f32) -> MediaFeatureValue {
let other = sign * 0.001;
match self {
MediaFeatureValue::Length(len) => MediaFeatureValue::Length(len.add(Length::px(other))),
// Blink's `CompareDoubleValue` gives a `min-`/`max-` length 1/64px of slack, so a smaller step does nothing.
MediaFeatureValue::Length(len) => {
MediaFeatureValue::Length(len.add(Length::px(sign * 0.02)))
}
MediaFeatureValue::Number(num) => MediaFeatureValue::Number(num + other),
MediaFeatureValue::Integer(num) => {
MediaFeatureValue::Integer(num + if other.is_sign_positive() { 1 } else { -1 })
MediaFeatureValue::Integer(num.saturating_add(sign as CSSInteger))
}
MediaFeatureValue::Boolean(v) => MediaFeatureValue::Boolean(v),
MediaFeatureValue::Resolution(res) => MediaFeatureValue::Resolution(res.add_f32(other)),
MediaFeatureValue::Ratio(ratio) => MediaFeatureValue::Ratio(ratio.add_f32(other)),
MediaFeatureValue::Ratio(ratio) => MediaFeatureValue::Ratio(ratio.step(sign)),
MediaFeatureValue::Ident(id) => MediaFeatureValue::Ident(id),
// env() values pass through unchanged — the calc-lattice add
// (wrapping the env() reference in a calc() to apply the
// strict-inequality boundary adjustment) is not implemented, so
// range bounds on env() values lose the +/-0.001 nudge.
// range bounds on env() values lose the step.
MediaFeatureValue::Env(env) => MediaFeatureValue::Env(env),
}
}
Expand Down Expand Up @@ -1221,20 +1245,134 @@ fn write_min_max<FeatureId: FeatureIdTrait>(

// `MediaFeatureValue: Clone`, so clone-by-value before adjusting.
let adjusted: Option<MediaFeatureValue> = match operator {
MediaFeatureComparison::GreaterThan => Some(value.clone().add_f32(0.001)),
MediaFeatureComparison::LessThan => Some(value.clone().add_f32(-0.001)),
MediaFeatureComparison::GreaterThan => Some(value.clone().step(1.0)),
MediaFeatureComparison::LessThan => Some(value.clone().step(-1.0)),
_ => None,
};

if let Some(val) = adjusted {
val.to_css(dest)?;
} else {
value.to_css(dest)?;
match adjusted.as_ref().unwrap_or(value) {
// Chrome before 110 and Safari before 16.4 read a ratio only as two integers.
MediaFeatureValue::Ratio(ratio) => ratio.to_css_both_terms(dest)?,
value => value.to_css(dest)?,
}

dest.write_char(b')')
}

/// A query that is one feature of media type `all`, with or without a `not`: `not all and (width < V)`.
struct LoneFeature<'a> {
/// The `MediaCondition::Feature` of the query.
condition: &'a MediaCondition,
/// The query starts with `not all and`.
not_all: bool,
/// The feature is inside `not (..)`, which needs the same browsers as range syntax.
written_not: bool,
}

impl MediaQuery {
/// `None` for a query that `LoneFeature::to_css_without_range_syntax` leaves as it is.
#[inline]
fn lone_feature(&self) -> Option<LoneFeature<'_>> {
if !matches!(self.media_type, MediaType::All) {
return None;
}
// `only` changes nothing in a browser that reads media queries.
let not_all = matches!(self.qualifier, Some(Qualifier::Not));
let (condition, written_not) = match &self.condition {
Some(MediaCondition::Not(inner)) if !not_all => (&**inner, true),
Some(condition) => (condition, false),
None => return None,
};
let MediaCondition::Feature(feature) = condition else {
return None;
};
// `(min-width: V)` parses as `width >= V`, so most queries stop here.
if !written_not && feature.strict_bound().is_none() {
return None;
}
Some(LoneFeature {
condition,
not_all,
written_not,
})
}
}

impl MediaFeature {
/// The inclusive comparison that a strict bound negates: `(width < V)` is "not `(width >= V)`".
#[inline]
fn strict_bound(
&self,
) -> Option<(
MediaFeatureComparison,
&MediaFeatureName<MediaFeatureId>,
&MediaFeatureValue,
)> {
let QueryFeature::Range {
name,
operator,
value,
} = self
else {
return None;
};
Comment thread
robobun marked this conversation as resolved.
Resolved
let negates = match operator {
MediaFeatureComparison::LessThan => MediaFeatureComparison::GreaterThanEqual,
MediaFeatureComparison::GreaterThan => MediaFeatureComparison::LessThanEqual,
_ => return None,
};
// An integer has an inclusive neighbour: `(color > 2)` is `(min-color: 3)`.
if matches!(value, MediaFeatureValue::Integer(_)) {
return None;
}
Some((negates, name, value))
}
}

impl LoneFeature<'_> {
/// For targets without range syntax, writes the query with its `not` on the whole query. Returns whether it did.
#[cold]
fn to_css_without_range_syntax(
&self,
dest: &mut Printer,
) -> core::result::Result<bool, PrintErr> {
if !dest
.targets
.should_compile_same(css::compat::Feature::MediaRangeSyntax)
{
return Ok(false);
}
let MediaCondition::Feature(feature) = self.condition else {
return Ok(false);
};
let strict = feature.strict_bound();
// `not (width < V)` is `(min-width: V)`.
let not = (self.not_all || self.written_not) != strict.is_some();
let (QueryFeature::Plain { name, .. }
| QueryFeature::Boolean { name }
| QueryFeature::Range { name, .. }
| QueryFeature::Interval { name, .. }) = &**feature;
// Safari before 16 never matches a `resolution` feature, so adding or removing `not all and` flips the result there.
if not != self.not_all
&& matches!(name, MediaFeatureName::Standard(MediaFeatureId::Resolution))
{
return Ok(false);
}
if not {
// Not lightningcss's `not (..)`: browsers without range syntax reject it (parcel-bundler/lightningcss#1361).
dest.write_str("not all and ")?;
}
let Some((negates, name, value)) = strict else {
// Through the condition: a second caller of `QueryFeature::to_css` takes it out of line for every query.
self.condition.to_css(dest)?;
return Ok(true);
};
dest.write_char(b'(')?;
write_min_max(negates, name, value, dest)?;
Ok(true)
}
}

// ───────────────────────── deep_clone ─────────────────────────
// Arena-aware `deep_clone`.
//
Expand Down
3 changes: 2 additions & 1 deletion src/css/rules/custom_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ impl CustomMediaRule {
dest.write_str("@custom-media ")?;
DashedIdentFns::to_css(&self.name, dest)?;
dest.write_char(b' ')?;
self.query.to_css(dest)?;
// `(--name)` puts this query inside other queries, where a `not all and` would negate those too.
self.query.to_css_as_written(dest)?;
dest.write_char(b';')?;
Ok(())
}
Expand Down
42 changes: 35 additions & 7 deletions src/css/values/ratio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,46 @@ impl Ratio {
}

pub(crate) fn to_css(self, dest: &mut Printer) -> core::result::Result<(), PrintErr> {
CSSNumberFns::to_css(self.numerator, dest)?;
if self.denominator != 1.0 {
dest.delim(b'/', true)?;
CSSNumberFns::to_css(self.denominator, dest)?;
return self.to_css_both_terms(dest);
}
Ok(())
CSSNumberFns::to_css(self.numerator, dest)
}

/// Writes `numerator / denominator`, also when the denominator is 1.
pub(crate) fn to_css_both_terms(
self,
dest: &mut Printer,
) -> core::result::Result<(), PrintErr> {
CSSNumberFns::to_css(self.numerator, dest)?;
dest.delim(b'/', true)?;
CSSNumberFns::to_css(self.denominator, dest)
}

pub(crate) fn add_f32(self, other: f32) -> Ratio {
/// Steps past the ratio for a strict bound. Integer terms stay integers: `16 / 9` steps up to `16001 / 9000`.
pub(crate) fn step(self, sign: f32) -> Ratio {
let Ratio {
numerator,
denominator,
} = self;
let integers = numerator >= 1.0
&& denominator >= 1.0
&& numerator.fract() == 0.0
&& denominator.fract() == 0.0;
if !integers {
return Ratio {
numerator: numerator + sign * 0.001,
denominator,
};
}
// At most 5 digits: Safari before 16.4 multiplies a term by the viewport size as a 32-bit float.
let mut scale = 1.0;
while numerator.max(denominator) * scale * 10.0 < 100_000.0 {
scale *= 10.0;
}
Ratio {
numerator: self.numerator + other,
denominator: self.denominator,
numerator: numerator * scale + sign,
denominator: denominator * scale,
}
}
}
57 changes: 56 additions & 1 deletion test/bundler/esbuild/css.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe } from "bun:test";
import { describe, expect } from "bun:test";
import { join } from "node:path";
import { itBundled } from "../expectBundled";

Expand Down Expand Up @@ -2144,6 +2144,61 @@ c {
outfile: "/out.css",
});

// The default browser targets have no range syntax (Chrome 80, Safari 14). A query
// that is one strict bound prints as a negation with the value as written, so
// `(width < 1024px)` and `(width >= 1024px)` never both match at 1024px. A strict
// bound beside another part steps .02px past its value.
for (const backend of ["cli", "api"] as const) {
itBundled(`css/MediaRangeStrictBound/${backend}`, {
backend,
files: {
"/entry.css": /* css */ `
@import "https://example.com/narrow.css" (width < 1024px);
@import "https://example.com/screen.css" screen and (width < 1024px);
@import "./tablet.css" (width < 1024px);
@import "./chain.css" (width > 640px);
@media (width < 1024px) { .nav { display: none } }
@media (width >= 1024px) { .burger { display: none } }
@media (width > 1280px) { .wide { color: red } }
@media (width < 64rem) { .rem { color: red } }
@media screen and (width < 768px) { .screen { color: red } }
.card { color: blue; @media (width < 768px) { color: red } }
`,
"/tablet.css": /* css */ `.tablet { color: red }`,
"/chain.css": /* css */ `@import "https://example.com/short.css" (height < 500px);`,
},
outfile: "/out.css",
onAfterBundle(api) {
const rule = (prelude: string, selector: string, declaration: string) =>
[`@media ${prelude} {`, ` ${selector} {`, ` ${declaration};`, " }", "}", ""].join("\n");
expect(api.readFile("/out.css")).toBe(
[
'@import "https://example.com/narrow.css" not all and (min-width: 1024px);',
'@import "https://example.com/screen.css" screen and (max-width: 1023.98px);',
'@import "data:text/css,@import \\"https://example.com/short.css\\" not all and (min-height: 500px);" not all and (max-width: 640px);',
"",
"/* tablet.css */",
rule("not all and (min-width: 1024px)", ".tablet", "color: red"),
"/* chain.css */",
"",
"",
"/* entry.css */",
rule("not all and (min-width: 1024px)", ".nav", "display: none"),
rule("(min-width: 1024px)", ".burger", "display: none"),
rule("not all and (max-width: 1280px)", ".wide", "color: red"),
rule("not all and (min-width: 64rem)", ".rem", "color: red"),
rule("screen and (max-width: 767.98px)", ".screen", "color: red"),
".card {",
" color: #00f;",
"}",
"",
rule("not all and (min-width: 768px)", ".card", "color: red"),
].join("\n"),
);
},
});
}

// This test mainly just makes sure that this scenario doesn't crash
itBundled("css/CSSAndJavaScriptCodeSplittingESBuildIssue1064", {
files: {
Expand Down
Loading
Loading