diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index a9e9208a3f56..768265512eee 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2384,6 +2384,7 @@ mod stylesheet_impl { let mut minify_ctx = MinifyContext { arena, targets: &options.targets, + resolution_prefixes: MediaList::resolution_prefixes(&options.targets), handler: &mut handler, important_handler: &mut important_handler, handler_context: ctx, diff --git a/src/css/media_query.rs b/src/css/media_query.rs index 2d1295755348..14cced60c694 100644 --- a/src/css/media_query.rs +++ b/src/css/media_query.rs @@ -678,6 +678,41 @@ impl MediaList { } dest.write_comma_separated(&self.media_queries, |d, q| q.to_css(d)) } + + /// The prefixes that need a device-pixel-ratio copy of a `resolution` query under `targets`. + pub(crate) fn resolution_prefixes(targets: &css::targets::Targets) -> css::VendorPrefix { + targets + .prefixes( + css::VendorPrefix::NONE, + css::prefixes::Feature::AtResolution, + ) + .intersection(css::VendorPrefix::WEBKIT | css::VendorPrefix::MOZ) + } + + /// Adds a device-pixel-ratio copy before each `resolution` query. Idempotent on the same list. + pub(crate) fn transform_resolution( + &mut self, + prefixes: css::VendorPrefix, + bump: &bun_alloc::Arena, + ) { + let mut i = 0; + while i < self.media_queries.len() { + if self.media_queries[i].has_resolution() { + for prefix in [css::VendorPrefix::WEBKIT, css::VendorPrefix::MOZ] { + if !prefixes.contains(prefix) { + continue; + } + let mut copy = self.media_queries[i].deep_clone(bump); + copy.transform_resolution(prefix); + if !self.media_queries.contains(©) { + self.media_queries.insert(i, copy); + i += 1; + } + } + } + i += 1; + } + } } impl MediaQuery { @@ -695,6 +730,18 @@ impl MediaQuery { && self.condition.is_none() } + fn has_resolution(&self) -> bool { + self.condition + .as_ref() + .is_some_and(MediaCondition::has_resolution) + } + + fn transform_resolution(&mut self, prefix: css::VendorPrefix) { + if let Some(condition) = &mut self.condition { + condition.transform_resolution(prefix); + } + } + pub fn to_css(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> { if let Some(qual) = self.qualifier { qual.to_css(dest)?; @@ -860,6 +907,80 @@ impl QueryCondition for MediaCondition { } } +impl MediaCondition { + fn has_resolution(&self) -> bool { + match self { + MediaCondition::Feature(feature) => feature.is_resolution(), + MediaCondition::Not(condition) => condition.has_resolution(), + MediaCondition::Operation { conditions, .. } => { + conditions.iter().any(MediaCondition::has_resolution) + } + } + } + + fn transform_resolution(&mut self, prefix: css::VendorPrefix) { + match self { + MediaCondition::Feature(feature) => feature.transform_resolution(prefix), + MediaCondition::Not(condition) => condition.transform_resolution(prefix), + MediaCondition::Operation { conditions, .. } => { + for condition in conditions.iter_mut() { + condition.transform_resolution(prefix); + } + } + } + } +} + +impl MediaFeature { + /// Whether this is a `resolution` feature whose values are all ``. + fn is_resolution(&self) -> bool { + use MediaFeatureName::Standard; + use MediaFeatureValue as V; + matches!( + self, + QueryFeature::Plain { + name: Standard(MediaFeatureId::Resolution), + value: V::Resolution(_), + } | QueryFeature::Range { + name: Standard(MediaFeatureId::Resolution), + value: V::Resolution(_), + .. + } | QueryFeature::Interval { + name: Standard(MediaFeatureId::Resolution), + start: V::Resolution(_), + end: V::Resolution(_), + .. + } + ) + } + + /// `prefix` is `WEBKIT` or `MOZ`. + fn transform_resolution(&mut self, prefix: css::VendorPrefix) { + if !self.is_resolution() { + return; + } + let ratio = MediaFeatureName::Standard(if prefix == css::VendorPrefix::WEBKIT { + MediaFeatureId::WebkitDevicePixelRatio + } else { + MediaFeatureId::MozDevicePixelRatio + }); + match self { + QueryFeature::Plain { name, value } | QueryFeature::Range { name, value, .. } => { + *name = ratio; + value.resolution_to_dppx(); + } + QueryFeature::Interval { + name, start, end, .. + } => { + *name = ratio; + start.resolution_to_dppx(); + end.resolution_to_dppx(); + } + QueryFeature::Boolean { .. } => {} + } + } +} + impl QueryFeature { pub(crate) fn needs_parens( &self, @@ -1074,6 +1195,12 @@ impl MediaFeatureValue { } } + fn resolution_to_dppx(&mut self) { + if let MediaFeatureValue::Resolution(resolution) = *self { + *self = MediaFeatureValue::Number(resolution.to_dppx()); + } + } + /// Adjust by `other` for strict-inequality → min/max /// boundary lowering. Consumes `self`. fn add_f32(self, other: f32) -> MediaFeatureValue { diff --git a/src/css/rules/mod.rs b/src/css/rules/mod.rs index 0a89042a286e..4e9df70ea072 100644 --- a/src/css/rules/mod.rs +++ b/src/css/rules/mod.rs @@ -513,7 +513,19 @@ impl CssRuleList { break 'arm; } } + CssRule::Import(imp) => { + // The bundler copies this list to the `@media` rule of an inlined import. + if !context.resolution_prefixes.is_empty() { + imp.media + .transform_resolution(context.resolution_prefixes, context.arena); + } + } CssRule::Media(med) => { + // Before the compare, so that rules with an equal query keep merging. + if !context.resolution_prefixes.is_empty() { + med.query + .transform_resolution(context.resolution_prefixes, context.arena); + } if let Some(CssRule::Media(last_rule)) = rules.last_mut() && last_rule.query.eql(&med.query) { @@ -1232,6 +1244,8 @@ pub struct MinifyContext<'a, 'bump> { /// Arena that owns the AST being minified (same arena it was parsed into). pub(crate) arena: &'bump bun_alloc::Arena, pub(crate) targets: &'a css::targets::Targets, + /// `MediaList::resolution_prefixes(targets)`, looked up once per stylesheet. + pub(crate) resolution_prefixes: css::VendorPrefix, pub(crate) handler: &'a mut css::DeclarationHandler<'bump>, pub(crate) important_handler: &'a mut css::DeclarationHandler<'bump>, pub(crate) handler_context: css::PropertyHandlerContext<'bump>, diff --git a/src/css/values/length.rs b/src/css/values/length.rs index 62c8e3a275c8..42c61d40df36 100644 --- a/src/css/values/length.rs +++ b/src/css/values/length.rs @@ -59,8 +59,9 @@ impl LengthPercentageOrAuto { } } -const PX_PER_IN: f32 = 96.0; -const PX_PER_CM: f32 = PX_PER_IN / 2.54; +pub(crate) const PX_PER_IN: f32 = 96.0; +pub(crate) const CM_PER_IN: f32 = 2.54; +const PX_PER_CM: f32 = PX_PER_IN / CM_PER_IN; const PX_PER_MM: f32 = PX_PER_CM / 10.0; const PX_PER_Q: f32 = PX_PER_CM / 40.0; const PX_PER_PT: f32 = PX_PER_IN / 72.0; diff --git a/src/css/values/resolution.rs b/src/css/values/resolution.rs index 2f80b4c09629..204eee4f447a 100644 --- a/src/css/values/resolution.rs +++ b/src/css/values/resolution.rs @@ -1,5 +1,6 @@ use crate::css_parser as css; use crate::css_parser::{CssResult as Result, Maybe, Parser, PrintErr, Printer, Token}; +use crate::values::length::{CM_PER_IN, PX_PER_IN}; use crate::values::number::CSSNumber; /// A CSS `` value. @@ -79,6 +80,15 @@ impl Resolution { css::serializer::serialize_dimension(value, unit, dest) } + /// The value in dppx. 1dppx is 96dpi, because 1in is 96px. + pub(crate) fn to_dppx(self) -> CSSNumber { + match self { + Resolution::Dpi(dpi) => dpi / PX_PER_IN, + Resolution::Dpcm(dpcm) => dpcm * CM_PER_IN / PX_PER_IN, + Resolution::Dppx(dppx) => dppx, + } + } + pub(crate) fn add_f32(self, other: f32) -> Resolution { match self { Resolution::Dpi(dpi) => Resolution::Dpi(dpi + other), diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 5e6a6f91ca01..7fa6338359b8 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -2117,6 +2117,141 @@ c { outfile: "/out.css", }); + // Safari 14, in the default browser targets, has no `resolution` media + // feature. It only knows `-webkit-device-pixel-ratio`. + itBundled("css/MediaResolutionVendorPrefix", { + files: { + "/entry.css": /* css */ ` + @media (min-resolution: 2dppx) { .a { color: red } } + @media (min-resolution: 2dppx) { .b { color: green } } + `, + + // The bundler copies the list of an inlined "@import" to a "@media" rule. + "/import.css": /* css */ ` + @import "./shared.css"; + @import "./shared.css" (min-resolution: 2dppx); + @import "./shared.css" (max-resolution: 192dpi), print; + `, + "/shared.css": `.shared { color: red }`, + + // The first import is redundant, but it still declares its layer. + "/layer.css": /* css */ ` + @import "./shared.css" layer(first) (min-resolution: 2dppx); + @import "./other.css" layer(second); + @import "./shared.css" layer(first) (min-resolution: 2dppx); + `, + "/other.css": `.other { color: green }`, + + "/external.css": /* css */ ` + @import "https://example.com/external.css" (min-resolution: 2dppx); + `, + + // The external import of the inner file and the rules of the inner file + // get the same condition. + "/nested.css": /* css */ ` + @import "./inner.css" (min-resolution: 2dppx); + `, + "/inner.css": /* css */ ` + @import "https://example.com/external.css" (min-width: 600px); + .inner { color: red } + `, + + // The two lists are equal after minify, so the first import is redundant. + "/duplicate.css": /* css */ ` + @import "./shared.css" (min-resolution: 2dppx); + @import "./shared.css" (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx); + `, + }, + entryPoints: ["/entry.css", "/import.css", "/layer.css", "/external.css", "/nested.css", "/duplicate.css"], + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(/* css */ ` +/* entry.css */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .a { + color: red; + } + + .b { + color: green; + } +} +`); + api.expectFile("/out/import.css").toEqualIgnoringWhitespace(/* css */ ` +/* shared.css */ +.shared { + color: red; +} + +/* shared.css */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .shared { + color: red; + } +} + +/* shared.css */ +@media (-webkit-max-device-pixel-ratio: 2), (max-resolution: 192dpi), print { + .shared { + color: red; + } +} + +/* import.css */ +`); + api.expectFile("/out/layer.css").toEqualIgnoringWhitespace(/* css */ ` +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + @layer first; +} + +/* other.css */ +@layer second { + .other { + color: green; + } +} + +/* shared.css */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + @layer first { + .shared { + color: red; + } + } +} + +/* layer.css */ +`); + api.expectFile("/out/external.css").toEqualIgnoringWhitespace(/* css */ ` +@import "https://example.com/external.css" (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx); + +/* external.css */ +`); + api.expectFile("/out/nested.css").toEqualIgnoringWhitespace(/* css */ ` +@import "data:text/css,@import \\"https://example.com/external.css\\" (min-width: 600px);" (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx); + +/* inner.css */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .inner { + color: red; + } +} + +/* nested.css */ +`); + api.expectFile("/out/duplicate.css").toEqualIgnoringWhitespace(/* css */ ` +/* shared.css */ +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .shared { + color: red; + } +} + +/* duplicate.css */ +`); + }, + }); + // This test mainly just makes sure that this scenario doesn't crash itBundled("css/CSSAndJavaScriptCodeSplittingESBuildIssue1064", { files: { diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 6e7b32d3b5e6..ac4be3d268a2 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -6402,7 +6402,6 @@ describe("css tests", () => { { safari: 15 << 16, }, - true, ); prefix_test( ` @@ -6420,7 +6419,6 @@ describe("css tests", () => { { firefox: 10 << 16, }, - true, ); prefix_test( ` @@ -6438,7 +6436,6 @@ describe("css tests", () => { { safari: 15 << 16, }, - true, ); prefix_test( ` @@ -6456,7 +6453,6 @@ describe("css tests", () => { { safari: 15 << 16, }, - true, ); prefix_test( ` @@ -6475,7 +6471,6 @@ describe("css tests", () => { safari: 15 << 16, firefox: 10 << 16, }, - true, ); prefix_test( ` @@ -6493,7 +6488,6 @@ describe("css tests", () => { { safari: 15 << 16, }, - true, ); prefix_test( ` @@ -6513,7 +6507,6 @@ describe("css tests", () => { safari: 15 << 16, firefox: 10 << 16, }, - true, ); prefix_test( ` @@ -6532,7 +6525,401 @@ describe("css tests", () => { safari: 15 << 16, firefox: 10 << 16, }, - true, + ); + prefix_test( + ` + @media (max-resolution: 2dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-max-device-pixel-ratio: 2), (max-resolution: 2dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + prefix_test( + ` + @media not (min-resolution: 2dppx) { + .foo { color: yellow; } + } + `, + ` + @media not (-webkit-min-device-pixel-ratio: 2), not (min-resolution: 2dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // Every resolution query of a list gets a prefixed copy. lightningcss 1.32.0 + // gives none to the query after one whose copy was already in the list + // (192dpi), or after one with a prefix that has no feature of its own (-o-). + prefix_test( + ` + @media (min-resolution: 2dppx), (min-resolution: 192dpi), (min-resolution: 3dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx), (min-resolution: 192dpi), (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + prefix_test( + ` + @media (min-resolution: 2dppx), (min-resolution: 3dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx), (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + opera: 12 << 16, + }, + ); + // Adjacent rules with an equal query stay merged, also when one of them + // already has the prefixed copy. lightningcss 1.32.0 does not merge the + // third rule. + prefix_test( + ` + @media (min-resolution: 2dppx) { + .foo { color: yellow; } + } + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .bar { color: green; } + } + @media (min-resolution: 2dppx) { + .baz { color: blue; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .foo { + color: #ff0; + } + + .bar { + color: green; + } + + .baz { + color: #00f; + } + } + `, + { + safari: 15 << 16, + }, + ); + // The merge of the outer rules minifies the nested rule again. The second + // pass adds nothing to its list. + prefix_test( + ` + @media (min-width: 1px) { + @media (min-resolution: 2dppx), (min-resolution: 192dpi) { + .foo { color: yellow; } + } + } + @media (min-width: 1px) { + .bar { color: green; } + } + @media (min-width: 1px) { + .baz { color: blue; } + } + `, + ` + @media (min-width: 1px) { + @media (-webkit-min-device-pixel-ratio: 2), (min--moz-device-pixel-ratio: 2), (min-resolution: 2dppx), (min-resolution: 192dpi) { + .foo { + color: #ff0; + } + } + + .bar { + color: green; + } + + .baz { + color: #00f; + } + } + `, + { + safari: 15 << 16, + firefox: 10 << 16, + }, + ); + prefix_test( + ` + .foo { + @media (min-resolution: 2dppx) { + color: yellow; + } + } + @supports (display: grid) { + @layer bar { + @media (min-resolution: 2dppx) { + .bar { color: green; } + } + } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .foo { + color: #ff0; + } + } + + @supports (display: grid) { + @layer bar { + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .bar { + color: green; + } + } + } + } + `, + { + safari: 15 << 16, + }, + ); + prefix_test( + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // Safari has the resolution feature from 16.0. + prefix_test( + ` + @media (min-resolution: 2dppx) { + .safari-15-6 { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { + .safari-15-6 { + color: #ff0; + } + } + `, + { + safari: (15 << 16) | (6 << 8), + }, + ); + prefix_test( + ` + @media (min-resolution: 2dppx) { + .safari-16 { color: yellow; } + } + `, + ` + @media (min-resolution: 2dppx) { + .safari-16 { + color: #ff0; + } + } + `, + { + safari: 16 << 16, + }, + ); + minify_test( + "@media (min-resolution: 2dppx) { .no-targets { color: yellow } }", + "@media (resolution>=2x){.no-targets{color:#ff0}}", + ); + // The exact form and the two-bound form get a copy too. lightningcss 1.32.0 + // and autoprefixer give these two forms no copy. + prefix_test( + ` + @media (resolution: 2dppx), (1dppx <= resolution <= 2dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-device-pixel-ratio: 2), (resolution: 2dppx), (-webkit-min-device-pixel-ratio: 1) and (-webkit-max-device-pixel-ratio: 2), (min-resolution: 1dppx) and (max-resolution: 2dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + prefix_test( + ` + @media (resolution: 96dpi), (1dppx < resolution < 2dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-moz-device-pixel-ratio: 1), (resolution: 96dpi), (min--moz-device-pixel-ratio: 1.001) and (max--moz-device-pixel-ratio: 1.999), (min-resolution: 1.001dppx) and (max-resolution: 1.999dppx) { + .foo { + color: #ff0; + } + } + `, + { + firefox: 10 << 16, + }, + ); + prefix_test( + ` + @media (-webkit-device-pixel-ratio: 2), (resolution: 2dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-device-pixel-ratio: 2), (resolution: 2dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // No copy for a value that is not a resolution, or for the boolean form. + prefix_test( + ` + @media (resolution: env(--foo)), (1dppx <= resolution <= env(--foo)), (resolution) { + .foo { color: yellow; } + } + `, + ` + @media (resolution: env(--foo)), (min-resolution: 1dppx) and (max-resolution: env(--foo)), (resolution) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // The copy of the two-bound form is one feature. A min and max pair that + // the author wrote is two features, so it is not seen as the copy. + prefix_test( + ` + @media (-webkit-min-device-pixel-ratio: 1) and (-webkit-max-device-pixel-ratio: 2), (1dppx <= resolution <= 2dppx) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 1) and (-webkit-max-device-pixel-ratio: 2), (-webkit-min-device-pixel-ratio: 1) and (-webkit-max-device-pixel-ratio: 2), (min-resolution: 1dppx) and (max-resolution: 2dppx) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // As in lightningcss, the equal-query check compares floats: 124.8dpi is + // not exactly 1.3dppx, so the query of the author is not seen as the copy. + prefix_test( + ` + @media (-webkit-min-device-pixel-ratio: 1.3), (min-resolution: 124.8dpi) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 1.3), (-webkit-min-device-pixel-ratio: 1.3), (min-resolution: 124.8dpi) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // As in lightningcss and autoprefixer, the copy makes this Chrome-only hack + // match in Safari 15 and older, which have no resolution feature. + prefix_test( + ` + @media all and (-webkit-min-device-pixel-ratio: 0) and (min-resolution: .001dpcm) { + .foo { color: yellow; } + } + `, + ` + @media (-webkit-min-device-pixel-ratio: 0) and (-webkit-min-device-pixel-ratio: 0.0000264583), (-webkit-min-device-pixel-ratio: 0) and (min-resolution: .001dpcm) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // This Safari-only hack still matches only in Safari. + prefix_test( + ` + @media not all and (min-resolution: .001dpcm) { + .foo { color: yellow; } + } + `, + ` + @media not all and (-webkit-min-device-pixel-ratio: 0.0000264583), not all and (min-resolution: .001dpcm) { + .foo { + color: #ff0; + } + } + `, + { + safari: 15 << 16, + }, + ); + // The list of an @import rule gets the same copy. The bundler puts this + // list on the @media rule that it wraps around an inlined import. + prefix_test( + ` + @import "foo.css" layer(foo) (min-resolution: 2dppx), print; + `, + ` + @import "foo.css" layer(foo) (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx), print; + `, + { + safari: 15 << 16, + }, ); error_test("@media (min-width: hi) { .foo { color: chartreuse }}", "ParserError::InvalidMediaQuery");