diff --git a/src/css/selectors/parser.rs b/src/css/selectors/parser.rs index 5e8df3665529..1b52f620fdaa 100644 --- a/src/css/selectors/parser.rs +++ b/src/css/selectors/parser.rs @@ -674,15 +674,13 @@ fn parse_selector( } let mut flags = SelectorFlags::empty(); - if state.contains(SelectorParsingState::AFTER_PSEUDO_ELEMENT) - || state.contains(SelectorParsingState::AFTER_UNKNOWN_PSEUDO_ELEMENT) - { + if state.contains(SelectorParsingState::HAS_PSEUDO_ELEMENT) { flags |= SelectorFlags::HAS_PSEUDO; } if state.contains(SelectorParsingState::AFTER_SLOTTED) { flags |= SelectorFlags::HAS_SLOTTED; } - if state.contains(SelectorParsingState::AFTER_PART) { + if state.contains(SelectorParsingState::AFTER_PART_LIKE) { flags |= SelectorFlags::HAS_PART; } let result = builder.build(flags); @@ -774,7 +772,7 @@ fn parse_compound_selector( builder.push_simple_selector(s); } SimpleSelectorParseResult::PartPseudo(selector) => { - state.insert(SelectorParsingState::AFTER_PART); + state.insert(SelectorParsingState::AFTER_PART_LIKE); builder.push_combinator(Combinator::Part); builder.push_simple_selector(GenericComponent::Part(selector)); } @@ -784,11 +782,14 @@ fn parse_compound_selector( builder.push_simple_selector(GenericComponent::Slotted(selector)); } SimpleSelectorParseResult::PseudoElement(p) => { + state.insert(SelectorParsingState::HAS_PSEUDO_ELEMENT); if !p.is_unknown() { - state.insert(SelectorParsingState::AFTER_PSEUDO_ELEMENT); + state.insert(if p.parses_as_element_backed() { + SelectorParsingState::AFTER_PART_LIKE + } else { + SelectorParsingState::AFTER_PSEUDO_ELEMENT + }); builder.push_combinator(Combinator::PseudoElement); - } else { - state.insert(SelectorParsingState::AFTER_UNKNOWN_PSEUDO_ELEMENT); } if !p.accepts_state_pseudo_classes() { @@ -1343,6 +1344,7 @@ impl<'a> SelectorParser<'a> { &mut self, name: Str, parser: &mut CssParser, + after_part_like: bool, ) -> CResult { let pseudo_class = crate::match_ignore_ascii_case! { name, { b"lang" => { @@ -1363,6 +1365,12 @@ impl<'a> SelectorParser<'a> { b"dir" => PseudoClass::Dir { direction: Direction::parse(parser)?, }, + // `.a::part(x):global(.g)` would print as `.a::part(x).g`, which is not a selector. + b"local" | b"global" if after_part_like && self.options.css_modules.is_some() => { + return Err(parser.new_custom_error( + SelectorParseErrorKind::InvalidState.into_default_parser_error(), + )); + }, b"local" if self.options.css_modules.is_some() => PseudoClass::Local { selector: Box::new(Selector::parse(self, parser)?), }, @@ -2609,15 +2617,13 @@ bitflags::bitflags! { /// whatever comes after them if so. const AFTER_SLOTTED = 1 << 1; - /// Whether we've parsed a ::part() pseudo-element already. + /// Whether we've parsed a ::part(), or a pseudo-element that parses like one, already. /// /// If so, then we can only parse a subset of pseudo-elements, and /// whatever comes after them if so. - const AFTER_PART = 1 << 2; + const AFTER_PART_LIKE = 1 << 2; - /// Whether we've parsed a pseudo-element (as in, an - /// `Impl::PseudoElement` thus not accounting for `::slotted` or - /// `::part`) already. + /// Whether we've parsed a known pseudo-element that does not parse like ::part() already. /// /// If so, then other pseudo-elements and most other selectors are /// disallowed. @@ -2640,14 +2646,16 @@ bitflags::bitflags! { const AFTER_WEBKIT_SCROLLBAR = 1 << 8; const AFTER_VIEW_TRANSITION = 1 << 9; - const AFTER_UNKNOWN_PSEUDO_ELEMENT = 1 << 10; + + /// Set for every `Impl::PseudoElement`. Only `SelectorFlags::HAS_PSEUDO` reads it. + const HAS_PSEUDO_ELEMENT = 1 << 10; } } impl SelectorParsingState { /// Whether we are after any of the pseudo-like things. pub(crate) fn after_any_pseudo(self) -> bool { - self.intersects(Self::AFTER_PART | Self::AFTER_SLOTTED | Self::AFTER_PSEUDO_ELEMENT) + self.intersects(Self::AFTER_PART_LIKE | Self::AFTER_SLOTTED | Self::AFTER_PSEUDO_ELEMENT) } pub(crate) fn allows_pseudos(self) -> bool { @@ -2676,7 +2684,7 @@ impl SelectorParsingState { } pub(crate) fn allows_custom_functional_pseudo_classes(self) -> bool { - !self.after_any_pseudo() + !self.intersects(Self::AFTER_SLOTTED | Self::AFTER_PSEUDO_ELEMENT) } } @@ -3029,6 +3037,42 @@ impl PseudoElement { ) } + /// Whether any pseudo-class or pseudo-element can follow, as after `::part()`. + pub(crate) fn parses_as_element_backed(&self) -> bool { + use PseudoElement as PE; + // No `_` arm, so that a new variant has to be classified here. + match self { + // https://drafts.csswg.org/css-pseudo-4/#element-backed (strict in lightningcss) + PE::DetailsContent => true, + // Part-like: https://github.com/w3c/csswg-drafts/issues/10758 (strict in lightningcss) + PE::PickerFunction { .. } => true, + PE::After + | PE::Before + | PE::FirstLine + | PE::FirstLetter + | PE::Selection(_) + | PE::Placeholder(_) + | PE::Marker + | PE::Backdrop(_) + | PE::FileSelectorButton(_) + | PE::WebkitScrollbar(_) + | PE::Cue + | PE::CueRegion + | PE::CueFunction { .. } + | PE::CueRegionFunction { .. } + | PE::ViewTransition + | PE::ViewTransitionGroup { .. } + | PE::ViewTransitionImagePair { .. } + | PE::ViewTransitionOld { .. } + | PE::ViewTransitionNew { .. } + | PE::ViewTransitionGroupChildren { .. } + | PE::PickerIcon + | PE::Checkmark + | PE::Custom { .. } + | PE::CustomFunction { .. } => false, + } + } + pub(crate) fn accepts_state_pseudo_classes(&self) -> bool { let _ = self; // Be lienient. @@ -3631,7 +3675,8 @@ pub(crate) fn parse_functional_pseudo_class( .new_custom_error(SelectorParseErrorKind::InvalidState.into_default_parser_error())); } - let result = parser.parse_non_ts_functional_pseudo_class(name, input)?; + let after_part_like = state.contains(SelectorParsingState::AFTER_PART_LIKE); + let result = parser.parse_non_ts_functional_pseudo_class(name, input, after_part_like)?; Ok(GenericComponent::NonTsPseudoClass(result)) } diff --git a/test/bundler/css/css-modules.test.ts b/test/bundler/css/css-modules.test.ts index 81c28666e055..aceca8ec4a7d 100644 --- a/test/bundler/css/css-modules.test.ts +++ b/test/bundler/css/css-modules.test.ts @@ -23,6 +23,58 @@ describe("css", () => { }, }); + // A pseudo-class is valid after ::part(), ::picker() and ::details-content. + itBundled("css-module/PseudoClassAfterPartLikePseudoElement", { + files: { + "index.module.css": /* css */ ` + :global(.a)::part(x):lang(en) { + color: red; + } + :global(.b)::picker(select):popover-open { + opacity: 1; + } + :global(.c)::details-content:not(:hover) { + height: auto; + } + `, + }, + outdir: "/out", + entryPoints: ["/index.module.css"], + onAfterBundle(api) { + api.expectFile("/out/index.module.css").toEqualIgnoringWhitespace(` + /* index.module.css */ + .a::part(x):lang(en) { + color: red; + } + .b::picker(select):popover-open { + opacity: 1; + } + .c::details-content:not(:hover) { + height: auto; + } + `); + }, + }); + + // :global() and :local() print their argument in place. `.a::part(x).g` is + // not a selector, so they stay an error there. + for (const [name, selector] of [ + ["Part", ".a::part(x):global(.g)"], + ["Picker", ".a::picker(select):global(.g)"], + ["DetailsContent", ".a::details-content:local(.l)"], + ]) { + itBundled(`css-module/GlobalOrLocalAfter${name}IsAnError`, { + files: { + "index.module.css": `${selector} { color: red; }`, + }, + outdir: "/out", + entryPoints: ["/index.module.css"], + bundleErrors: { + "/index.module.css": ["Invalid selector. Token is not allowed in this state"], + }, + }); + } + itBundled("css-module/BundleTwoFilesWithoutCodeSplitting", { files: { "/foo-entry.js": ` diff --git a/test/bundler/css/forms-pseudo-elements-41120.test.ts b/test/bundler/css/forms-pseudo-elements-41120.test.ts index bdd33443ae57..a8e97d1268ae 100644 --- a/test/bundler/css/forms-pseudo-elements-41120.test.ts +++ b/test/bundler/css/forms-pseudo-elements-41120.test.ts @@ -1,7 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import path from "node:path"; +// A pseudo-class or a pseudo-element can follow ::details-content and +// ::picker(), as after ::part(). 1.4.0 built these sheets. 1.4.1 refused the +// first and the third, and emptied the :is() in the second. +const partLikeSheets = { + "select.css": ` + .label { color: red } + select, ::picker(select) { appearance: base-select } + ::picker(select) { opacity: 0; transition: opacity 0.4s } + ::picker(select):popover-open { opacity: 1 } + @starting-style { ::picker(select):popover-open { opacity: 0 } } + .footer { color: blue } + `, + "is.css": `::picker(select):is(:popover-open) { opacity: 1 }`, + "details.css": `details::details-content::before { content: "" }`, +}; +const partLikeMinified = { + "select.css": + ".label{color:red}select{appearance:base-select}::picker(select){appearance:base-select}::picker(select){opacity:0;transition:opacity .4s}::picker(select):popover-open{opacity:1}@starting-style{::picker(select):popover-open{opacity:0}}.footer{color:#00f}", + "is.css": "::picker(select):popover-open{opacity:1}", + "details.css": 'details::details-content:before{content:""}', +}; + // https://github.com/oven-sh/bun/issues/41120 // ::details-content, ::picker(), ::checkmark and ::picker-icon are known // pseudo-elements (css-pseudo-4 and css-forms-1, also in lightningcss's @@ -45,4 +67,54 @@ describe("css", () => { const out = await result.outputs[0].text(); expect(out.trim()).toBe(".a::details-content{height:auto}.b::checkmark{color:teal}"); }); + + test.concurrent("a pseudo-class or a pseudo-element after ::picker() and ::details-content builds", async () => { + using dir = tempDir("css-part-like", partLikeSheets); + const result = await Bun.build({ + entrypoints: Object.keys(partLikeSheets).map(file => path.join(String(dir), file)), + minify: true, + throw: true, + }); + expect(result.logs.map(String)).toEqual([]); + const outputs = await Promise.all( + result.outputs.map(async output => [path.basename(output.path), (await output.text()).trim()]), + ); + expect(Object.fromEntries(outputs)).toEqual(partLikeMinified); + }); + + test.concurrent("bun build writes a sheet that has ::picker(select):popover-open", async () => { + using dir = tempDir("css-part-like-cli", partLikeSheets); + const files = Object.keys(partLikeSheets); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", ...files.map(file => `./${file}`), "--minify", "--outdir", "out"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const written = await Promise.all( + files.map(async file => [file, (await Bun.file(path.join(String(dir), "out", file)).text()).trim()]), + ); + expect(Object.fromEntries(written)).toEqual(partLikeMinified); + expect(exitCode).toBe(0); + }); + + // Nesting is lowered to `::picker(select):popover-open`. That output is a + // valid sheet, so a second build of it gives the same bytes. + test.concurrent("the lowered form of a rule nested in ::picker() builds again", async () => { + using dir = tempDir("css-part-like-nested", { + "nested.css": `::picker(select) { opacity: 0; &:popover-open { opacity: 1 } }`, + }); + const build = async (file: string) => { + const result = await Bun.build({ entrypoints: [path.join(String(dir), file)], minify: true, throw: true }); + expect(result.logs.map(String)).toEqual([]); + return (await result.outputs[0].text()).trim(); + }; + const lowered = await build("nested.css"); + expect(lowered).toBe("::picker(select){opacity:0}::picker(select):popover-open{opacity:1}"); + await Bun.write(path.join(String(dir), "lowered.css"), lowered); + expect(await build("lowered.css")).toBe(lowered); + }); }); diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 6e7b32d3b5e6..d96f1b3a9e7d 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -5805,6 +5805,92 @@ describe("css tests", () => { }); } + // Any pseudo-class or pseudo-element can follow ::details-content and + // ::picker(), as after ::part(). ::details-content is an element-backed + // pseudo-element (https://drafts.csswg.org/css-pseudo-4/#element-backed). + // ::picker() is part-like by https://github.com/w3c/csswg-drafts/issues/10758. + for (const name of ["::picker(select)", "::details-content"]) { + for (const origin of ["", "a", "::part(x)"]) { + const sel = origin + name; + minify_test(`${sel}:popover-open {color:red}`, `${sel}:popover-open{color:red}`); + minify_test(`${sel}:open {color:red}`, `${sel}:open{color:red}`); + minify_test(`${sel}:not(:popover-open) {color:red}`, `${sel}:not(:popover-open){color:red}`); + minify_test(`${sel}:is(:popover-open, :hover) {color:red}`, `${sel}:is(:popover-open,:hover){color:red}`); + minify_test(`${sel}:where(:popover-open) {color:red}`, `${sel}:where(:popover-open){color:red}`); + minify_test(`${sel}:dir(rtl) {color:red}`, `${sel}:dir(rtl){color:red}`); + minify_test(`${sel}::before {color:red}`, `${sel}:before{color:red}`); + } + minify_test(`::slotted(x)${name}::before {color:red}`, `::slotted(x)${name}:before{color:red}`); + minify_test( + `@starting-style { ${name}:popover-open {opacity:0} }`, + `@starting-style{${name}:popover-open{opacity:0}}`, + ); + minify_test(`@scope (.a) to (${name}:open) { .b {color:red} }`, `@scope(.a) to (${name}:open){.b{color:red}}`); + + // These stay an error on purpose. Chrome drops each of these rules. + test.each([ + [`${name}::before:popover-open`, "Invalid pseudo-class after pseudo-element"], + [`${name}::before:dir(rtl)`, "Token is not allowed in this state"], + [`${name}::before::after`, "Token is not allowed in this state"], + [`${name}::part(x)`, "Token is not allowed in this state"], + [`${name}::slotted(x)`, "Token is not allowed in this state"], + [`${name}:nth-child(2)`, "Token is not allowed in this state"], + [`${name}.x`, "Unexpected selector after pseudo-element: ."], + [`${name}[open]`, "Unexpected selector after pseudo-element: ["], + [`${name}:not(.x)`, "Unexpected selector after pseudo-element: ."], + [`${name} .x`, "Unexpected selector after pseudo-element: ."], + [`${name} > .x`, "Unexpected selector after pseudo-element: >"], + [`::before${name}`, "Token is not allowed in this state"], + ])("ERROR: %s {color:red}", (selector, message) => { + expect(() => minify_test_with_options(`${selector} {color:red}`, "")).toThrow(message); + }); + + // ::slotted() takes no pseudo-class after it. That still holds after + // ::slotted(x)::details-content and ::slotted(x)::picker(), as in 1.4.0. + test.each([":popover-open", ":open", ":not(:popover-open)", ":dir(rtl)"])( + `ERROR: ::slotted(x)${name}%s {color:red}`, + pseudoClass => { + expect(() => minify_test_with_options(`::slotted(x)${name}${pseudoClass} {color:red}`, "")).toThrow( + "Token is not allowed in this state", + ); + }, + ); + } + + // A functional pseudo-class is valid after ::part(). + minify_test("::part(x):dir(rtl) {color:red}", "::part(x):dir(rtl){color:red}"); + minify_test("::part(x):lang(en) {color:red}", "::part(x):lang(en){color:red}"); + minify_test("::part(x):state(y) {color:red}", "::part(x):state(y){color:red}"); + minify_test("::part(x):is(:dir(rtl)) {color:red}", "::part(x):dir(rtl){color:red}"); + minify_test("::part(x):not(:lang(en)) {color:red}", "::part(x):not(:lang(en)){color:red}"); + + // ::checkmark and ::picker-icon are not element-backed: only a user action + // pseudo-class can follow them. ::slotted() takes no pseudo-class after it. + // Chrome drops each of these rules. + test.each([ + ["::checkmark:checked", "Invalid pseudo-class after pseudo-element"], + ["::picker-icon:disabled", "Invalid pseudo-class after pseudo-element"], + ["::checkmark::before", "Token is not allowed in this state"], + ["::picker-icon:dir(rtl)", "Token is not allowed in this state"], + ["::slotted(x):hover", "Token is not allowed in this state"], + ["::slotted(x):dir(rtl)", "Token is not allowed in this state"], + ["::slotted(x)::before:dir(rtl)", "Token is not allowed in this state"], + ])("ERROR: %s {color:red}", (selector, message) => { + expect(() => minify_test_with_options(`${selector} {color:red}`, "")).toThrow(message); + }); + + // :is() cannot hold a pseudo-element, so a list that has one is split. + prefix_test( + "a::picker(select), b::picker(select) {color:red}", + "a::picker(select) { color: red; } b::picker(select) { color: red; }", + { chrome: 100 << 16 }, + ); + prefix_test( + "a::details-content:open, b::details-content:open {color:red}", + "a::details-content:open { color: red; } b::details-content:open { color: red; }", + { chrome: 100 << 16 }, + ); + minify_test(".foo ::deep .bar {width: 20px}", ".foo ::deep .bar{width:20px}"); minify_test(".foo::deep .bar {width: 20px}", ".foo::deep .bar{width:20px}"); minify_test(".foo ::deep.bar {width: 20px}", ".foo ::deep.bar{width:20px}");