From fbc0e32f8411a5670491e42b484f2a08269ec930 Mon Sep 17 00:00:00 2001 From: "Henry Q. Dineen" Date: Sat, 31 Oct 2026 01:39:25 -0400 Subject: [PATCH] [style-value-parser] Fix media queries with a leading `not` In CSS a leading `not` negates the whole media query: `not all and (monochrome)` means `not (all and (monochrome))`. The parser bound the `not` to the media type alone, reading it as `(not all) and (monochrome)`. That used to round-trip back to the original string, so it went unnoticed. #1888 added media-type resolution, which simplifies the misread tree into wrong CSS: on main, `not all and (monochrome)` compiles to `not all` (never matches) and `not screen and (min-width: 100px)` to `print and (min-width: 100px)`. #1888 updated three snapshots to that output. 0.19.1 is also wrong when such a query is combined with other media queries on the same property: the leading `not` swallows the appended negations, or produces `(not not all)`. - Parse `not and ` as `not ( and )`. - Keep a standalone leading-`not` query in that form when serializing, so it still compiles to exactly what was written. The three #1888 snapshots go back to their original strings. Only combined queries are rewritten. - When resolving a negation, apply De Morgan before expanding the operand. Expanding first made `not screen and ((a) or (b) or ...)` grow combinatorially once combined with another query. - Parenthesize an `or` inside an `and` when serializing. Without this, `(min-width: 1px) and ((max-width: 2px) or (max-height: 3px))` lost its inner parentheses and became invalid (already broken on main). - Flatten nested `and` rules while resolving media types, so a media type from a negated later query moves to the front instead of ending up mid-query (`(max-width: 900px) and print and ...`, already broken on main for `not print, (max-width: 100px)`). Co-Authored-By: Claude Opus 5.5 --- .../__tests__/transform-stylex-create-test.js | 78 ++++++++ .../__tests__/media-query-transform-test.js | 166 ++++++++++++++++++ .../__tests__/parse-media-query-test.js | 79 +++++++-- .../src/at-queries/media-query.js | 69 ++++++-- 4 files changed, 367 insertions(+), 25 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js index c7136a9e2..a1f27e40e 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-create-test.js @@ -2553,6 +2553,84 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('media queries with a leading not and last query wins', () => { + const { code, metadata } = transform( + ` + import * as stylex from '@stylexjs/stylex'; + export const styles = stylex.create({ + root: { + filter: { + default: 'grayscale(1)', + '@media not all and (monochrome)': 'none', + }, + backgroundColor: { + default: 'red', + '@media not screen and (min-width: 100px)': 'blue', + '@media (min-width: 500px)': 'purple', + } + }, + }); + `, + { enableMediaQueryOrder: true }, + ); + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const styles = { + root: { + ku685b: "xifi7vn x342vwv", + kWkggS: "xrkmrrc x8tgh9g x132kqxt", + $$css: true + } + };" + `); + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "xifi7vn", + { + "ltr": ".xifi7vn{filter:grayscale(1)}", + "rtl": null, + }, + 3000, + ], + [ + "x342vwv", + { + "ltr": "@media not all and (monochrome){.x342vwv.x342vwv{filter:none}}", + "rtl": null, + }, + 3200, + ], + [ + "xrkmrrc", + { + "ltr": ".xrkmrrc{background-color:red}", + "rtl": null, + }, + 3000, + ], + [ + "x8tgh9g", + { + "ltr": "@media print and (not (min-width: 500px)), (max-width: 99.98px){.x8tgh9g.x8tgh9g{background-color:blue}}", + "rtl": null, + }, + 3200, + ], + [ + "x132kqxt", + { + "ltr": "@media (min-width: 500px){.x132kqxt.x132kqxt{background-color:purple}}", + "rtl": null, + }, + 3200, + ], + ], + } + `); + }); + test('media queries without last query wins', () => { const { code, metadata } = transform( ` diff --git a/packages/style-value-parser/src/at-queries/__tests__/media-query-transform-test.js b/packages/style-value-parser/src/at-queries/__tests__/media-query-transform-test.js index d090fb519..1999d7747 100644 --- a/packages/style-value-parser/src/at-queries/__tests__/media-query-transform-test.js +++ b/packages/style-value-parser/src/at-queries/__tests__/media-query-transform-test.js @@ -390,6 +390,172 @@ describe('Media Query Transformer', () => { expect(JSON.stringify(result)).toBe(JSON.stringify(expectedStyles)); }); + test('a leading not negates the whole query', () => { + const originalStyles = { + filter: { + default: 'grayscale(1)', + '@media not all and (monochrome)': 'none', + }, + color: { + default: 'black', + '@media not screen and (min-width: 100px)': 'red', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media not screen and (min-width: 100px)": "red", + "default": "black", + }, + "filter": { + "@media not all and (monochrome)": "none", + "default": "grayscale(1)", + }, + } + `); + }); + + test('a leading not followed by a later query', () => { + const originalStyles = { + color: { + default: 'black', + '@media not all and (monochrome)': 'blue', + '@media (min-width: 500px)': 'red', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media (min-width: 500px)": "red", + "@media (not (min-width: 500px)) and (not (monochrome))": "blue", + "default": "black", + }, + } + `); + }); + + test('a later query with a leading not', () => { + const originalStyles = { + color: { + default: 'black', + '@media (min-width: 500px)': 'red', + '@media not all and (monochrome)': 'blue', + }, + width: { + default: '100%', + '@media (max-width: 900px)': '80%', + '@media not print and (min-width: 600px)': '60%', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media (min-width: 500px) and (monochrome)": "red", + "@media not all and (monochrome)": "blue", + "default": "black", + }, + "width": { + "@media not print and (min-width: 600px)": "60%", + "@media print and (max-width: 900px) and (min-width: 600px)": "80%", + "default": "100%", + }, + } + `); + }); + + test('a leading not over an or condition stays linear in size', () => { + const originalStyles = { + color: { + default: 'black', + '@media (min-width: 10px)': 'red', + '@media not screen and ((min-width: 100px) or (min-width: 200px) or (min-width: 300px) or (min-width: 400px))': + 'blue', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media not screen and ((min-width: 100px) or (min-width: 200px) or (min-width: 300px) or (min-width: 400px))": "blue", + "@media screen and (min-width: 100px) and (min-width: 10px), screen and (min-width: 200px) and (min-width: 10px), screen and (min-width: 300px) and (min-width: 10px), screen and (min-width: 400px) and (min-width: 10px)": "red", + "default": "black", + }, + } + `); + }); + + test('a leading not over mixed and/or conditions', () => { + const originalStyles = { + color: { + default: 'black', + '@media not all and (min-width: 500px) and ((max-width: 900px) or (max-height: 600px))': + 'blue', + }, + width: { + default: '100%', + '@media (min-width: 10px)': '80%', + '@media not all and (min-width: 500px) and ((max-width: 900px) or (max-height: 600px))': + '60%', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media not all and (min-width: 500px) and ((max-width: 900px) or (max-height: 600px))": "blue", + "default": "black", + }, + "width": { + "@media (max-width: 900px) and (min-width: 10px) and (min-width: 500px), (max-height: 600px) and (min-width: 10px) and (min-width: 500px)": "80%", + "@media not all and (min-width: 500px) and ((max-width: 900px) or (max-height: 600px))": "60%", + "default": "100%", + }, + } + `); + }); + + test('an or condition inside an and keeps its parentheses', () => { + const originalStyles = { + color: { + default: 'black', + '@media (min-width: 1px) and ((max-width: 200px) or (max-height: 600px))': + 'blue', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media (min-width: 1px) and ((max-width: 200px) or (max-height: 600px))": "blue", + "default": "black", + }, + } + `); + }); + + test('a media type from a negated later query is moved to the front', () => { + const originalStyles = { + color: { + default: 'black', + '@media (max-width: 900px)': 'red', + '@media not print, (max-width: 100px)': 'blue', + }, + }; + + expect(lastMediaQueryWinsTransform(originalStyles)).toMatchInlineSnapshot(` + { + "color": { + "@media not print, (max-width: 100px)": "blue", + "@media print and (max-width: 900px) and (not (max-width: 100px))": "red", + "default": "black", + }, + } + `); + }); + test('basic usage: does not modify single queries', () => { const originalStyles = { gridColumn: { diff --git a/packages/style-value-parser/src/at-queries/__tests__/parse-media-query-test.js b/packages/style-value-parser/src/at-queries/__tests__/parse-media-query-test.js index 6032c3bd8..624672a1c 100644 --- a/packages/style-value-parser/src/at-queries/__tests__/parse-media-query-test.js +++ b/packages/style-value-parser/src/at-queries/__tests__/parse-media-query-test.js @@ -156,13 +156,28 @@ describe('style-value-parser/at-queries', () => { expect(parsed).toMatchInlineSnapshot(` MediaQuery { "queries": { - "key": "all", - "not": true, - "type": "media-keyword", + "rule": { + "rules": [ + { + "key": "all", + "not": false, + "only": false, + "type": "media-keyword", + }, + { + "keyValue": "monochrome", + "type": "word-rule", + }, + ], + "type": "and", + }, + "type": "not", }, } `); - expect(parsed.toString()).toMatchInlineSnapshot('"@media not all"'); + expect(parsed.toString()).toMatchInlineSnapshot( + '"@media not all and (monochrome)"', + ); }); }); @@ -945,13 +960,28 @@ describe('style-value-parser/at-queries', () => { expect(parsed).toMatchInlineSnapshot(` MediaQuery { "queries": { - "key": "all", - "not": true, - "type": "media-keyword", + "rule": { + "rules": [ + { + "key": "all", + "not": false, + "only": false, + "type": "media-keyword", + }, + { + "keyValue": "monochrome", + "type": "word-rule", + }, + ], + "type": "and", + }, + "type": "not", }, } `); - expect(parsed.toString()).toMatchInlineSnapshot('"@media not all"'); + expect(parsed.toString()).toMatchInlineSnapshot( + '"@media not all and (monochrome)"', + ); }); test('@media screen and (min-width: 400px)', () => { @@ -2033,13 +2063,38 @@ describe('style-value-parser/at-queries', () => { expect(parsed).toMatchInlineSnapshot(` MediaQuery { "queries": { - "key": "all", - "not": true, - "type": "media-keyword", + "rule": { + "rules": [ + { + "key": "all", + "not": false, + "only": false, + "type": "media-keyword", + }, + { + "keyValue": "monochrome", + "type": "word-rule", + }, + { + "key": "min-width", + "type": "pair", + "value": { + "signCharacter": undefined, + "type": "integer", + "unit": "px", + "value": 600, + }, + }, + ], + "type": "and", + }, + "type": "not", }, } `); - expect(parsed.toString()).toMatchInlineSnapshot('"@media not all"'); + expect(parsed.toString()).toMatchInlineSnapshot( + '"@media not all and (monochrome) and (min-width: 600px)"', + ); }); test('@media (max-width: 1440px) and (not (max-width: 1024px)) and (not (max-width: 768px)) and (not (max-width: 458px))', () => { diff --git a/packages/style-value-parser/src/at-queries/media-query.js b/packages/style-value-parser/src/at-queries/media-query.js index 6a0a25f1b..b8b6bb59e 100644 --- a/packages/style-value-parser/src/at-queries/media-query.js +++ b/packages/style-value-parser/src/at-queries/media-query.js @@ -261,7 +261,17 @@ const mediaAndRulesParser: TokenParser = (rules: $ReadOnlyArray): implies rules is $ReadOnlyArray => Array.isArray(rules) && rules.length > 1, ) - .map((rules) => (rules.length === 1 ? rules[0] : { type: 'and', rules })); + .map((rules) => { + if (rules.length === 1) return rules[0]; + const [first, ...rest] = rules; + if (first.type === 'media-keyword' && first.not) { + return { + type: 'not', + rule: { type: 'and', rules: [{ ...first, not: false }, ...rest] }, + }; + } + return { type: 'and', rules }; + }); // update mediaOrRulesParser to use lazy notParser reference const mediaOrRulesParser: TokenParser = @@ -617,10 +627,28 @@ function containsMediaKeyword(node: MediaQueryRule): boolean { } } +function isLeadingNotQuery(node: MediaQueryRule): boolean { + if (node.type !== 'not' || node.rule.type !== 'and') { + return false; + } + const [first, ...rest] = node.rule.rules; + return ( + first.type === 'media-keyword' && + !first.not && + !rest.some(containsMediaKeyword) + ); +} + // Rewrites an already-normalized media AST so that media types are expressed as // valid CSS: a single positive anchoring media type per query, distributing // over `or` branches and dropping branches that can never match. -function resolveMediaTypes(node: MediaQueryRule): MediaQueryRule { +function resolveMediaTypes( + node: MediaQueryRule, + isQueryList: boolean = false, +): MediaQueryRule { + if (isQueryList && isLeadingNotQuery(node)) { + return node; + } if (!containsMediaKeyword(node)) { return node; } @@ -628,7 +656,7 @@ function resolveMediaTypes(node: MediaQueryRule): MediaQueryRule { case 'or': { const rules: Array = []; for (const child of node.rules) { - for (const r of flattenOrRule(resolveMediaTypes(child))) { + for (const r of flattenOrRule(resolveMediaTypes(child, isQueryList))) { if (!isNeverRule(r)) { rules.push(r); } @@ -640,7 +668,10 @@ function resolveMediaTypes(node: MediaQueryRule): MediaQueryRule { return { type: 'or', rules }; } case 'and': { - const resolved = node.rules.map(resolveMediaTypes); + const resolved = node.rules.flatMap((rule) => { + const r = resolveMediaTypes(rule); + return r.type === 'and' ? r.rules : [r]; + }); // Distribute over a nested `or` so each branch anchors its own media type. const orIndex = resolved.findIndex((r) => r.type === 'or'); @@ -701,14 +732,15 @@ function resolveMediaTypes(node: MediaQueryRule): MediaQueryRule { return { type: 'and', rules }; } case 'not': { - const inner = resolveMediaTypes(node.rule); - if (inner.type === 'media-keyword') { - return resolveMediaTypes({ ...inner, not: !inner.not }); - } - if (inner.type === 'and' || inner.type === 'or') { + const inner = node.rule; + if ( + inner.type === 'media-keyword' || + inner.type === 'and' || + inner.type === 'or' + ) { return resolveMediaTypes(negateRule(inner)); } - return { type: 'not', rule: inner }; + return { type: 'not', rule: resolveMediaTypes(inner) }; } default: return node; @@ -718,7 +750,7 @@ function resolveMediaTypes(node: MediaQueryRule): MediaQueryRule { export class MediaQuery { queries: MediaQueryRule; constructor(queries: MediaQueryRule) { - this.queries = resolveMediaTypes(MediaQuery.normalize(queries)); + this.queries = resolveMediaTypes(MediaQuery.normalize(queries), true); } toString(): string { return `@media ${this.#toString(this.queries, true)}`; @@ -764,6 +796,9 @@ export class MediaQuery { ); } case 'not': + if (isTopLevel && isLeadingNotQuery(queries)) { + return `not ${this.#toString(queries.rule)}`; + } return queries.rule && (queries.rule.type === 'and' || queries.rule.type === 'or' || @@ -771,7 +806,15 @@ export class MediaQuery { ? `(not (${this.#toString(queries.rule)}))` : `(not ${this.#toString(queries.rule)})`; case 'and': - return queries.rules.map((rule) => this.#toString(rule)).join(' and '); + return queries.rules + .map((rule) => + queries.rules.length > 1 && + rule.type === 'or' && + rule.rules.length > 1 + ? `(${this.#toString(rule)})` + : this.#toString(rule), + ) + .join(' and '); case 'or': { const validRules = queries.rules.filter( (r) => !(r.type === 'or' && r.rules.length === 0), @@ -786,7 +829,7 @@ export class MediaQuery { const result = !isTopLevel ? `(${ruleString})` : ruleString; return result; } - return this.#toString(rule); + return this.#toString(rule, isTopLevel); }); return isTopLevel