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
79 changes: 62 additions & 17 deletions src/css/selectors/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,15 +674,13 @@ fn parse_selector<Impl: BunSelectorImpl>(
}

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);
Expand Down Expand Up @@ -774,7 +772,7 @@ fn parse_compound_selector<Impl: BunSelectorImpl>(
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));
}
Expand All @@ -784,11 +782,14 @@ fn parse_compound_selector<Impl: BunSelectorImpl>(
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() {
Expand Down Expand Up @@ -1343,6 +1344,7 @@ impl<'a> SelectorParser<'a> {
&mut self,
name: Str,
parser: &mut CssParser,
after_part_like: bool,
) -> CResult<PseudoClass> {
let pseudo_class = crate::match_ignore_ascii_case! { name, {
b"lang" => {
Expand All @@ -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() => {
Comment thread
robobun marked this conversation as resolved.
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)?),
},
Comment thread
robobun marked this conversation as resolved.
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3631,7 +3675,8 @@ pub(crate) fn parse_functional_pseudo_class<Impl: BunSelectorImpl>(
.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))
}
Expand Down
52 changes: 52 additions & 0 deletions test/bundler/css/css-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": `
Expand Down
74 changes: 73 additions & 1 deletion test/bundler/css/forms-pseudo-elements-41120.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
});
});
Loading
Loading