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
1 change: 1 addition & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions src/css/media_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&copy) {
Comment thread
robobun marked this conversation as resolved.
Resolved
self.media_queries.insert(i, copy);
i += 1;
}
}
}
i += 1;
}
}
}

impl MediaQuery {
Expand All @@ -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)?;
Expand Down Expand Up @@ -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 `<resolution>`.
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<FeatureId: FeatureIdTrait> QueryFeature<FeatureId> {
pub(crate) fn needs_parens(
&self,
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions src/css/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,19 @@ impl<R> CssRuleList<R> {
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)
{
Expand Down Expand Up @@ -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>,
Expand Down
5 changes: 3 additions & 2 deletions src/css/values/length.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/css/values/resolution.rs
Original file line number Diff line number Diff line change
@@ -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 `<resolution>` value.
Expand Down Expand Up @@ -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),
Expand Down
135 changes: 135 additions & 0 deletions test/bundler/esbuild/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading