Skip to content
Merged
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
42 changes: 29 additions & 13 deletions crates/oxc_transformer/src/plugins/styled_components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,20 +1062,36 @@ fn minify_template_literal<'a>(lit: &mut TemplateLiteral<'a>, ast: AstBuilder<'a
// Skip and compress whitespace.
_ if cur_byte.is_ascii_whitespace() => {
i += 1;
// Compress symbols, remove spaces around these symbols,
// but preserve whitespace preceding colon, to avoid joining selectors.
if output.last().is_some_and(|&last| {
!matches!(last, b' ' | b':' | b'{' | b'}' | b',' | b';')
}) && (i == bytes.len() || !matches!(bytes[i], b'{' | b'}' | b',' | b';'))
// Decide whether to preserve this whitespace character.
// CSS allows removing spaces around certain delimiters without changing meaning:
// - `color: red` -> `color:red` (spaces around colons)
// - `.a { }` -> `.a{}` (spaces around braces)
// - `margin: 1px , 2px` -> `margin:1px,2px` (spaces around commas)
// But spaces are significant in other contexts like selectors: `.a .b` != `.a.b`
if output.last().map_or(
// Case 1: If output is empty (no last char), preserve space only if we're
// in a non-first quasi to avoid joining with the previous interpolation.
// Example: `${A} ${B}` - the space between interpolations must be preserved
quasi_index != 0,
// Case 2: If we have a last char, preserve space unless it's a CSS delimiter
// that can safely have adjacent spaces removed (space, colon, braces, comma, semicolon)
|&last| !matches!(last, b' ' | b':' | b'{' | b'}' | b',' | b';'),
)
// AND check what comes after this whitespace:
// - If we're at the end of the quasi (i == bytes.len()), preserve the space
// to avoid joining with the next interpolation
// - Otherwise, only preserve if the next char is NOT one of: { } , ;
// Note: We intentionally DON'T include ':' here because spaces before colons
// are significant in CSS. ` :hover` (descendant pseudo-selector) is different
// from `:hover` (direct pseudo-selector). Example: `.parent :hover` selects any
// hovered descendant, while `.parent:hover` selects the parent when hovered.
&& bytes.get(i).is_none_or(|&next| !matches!(next, b'{' | b'}' | b',' | b';'))
{
// `i == bytes.len()` means we're at the end of the quasi that has an
// interpolation after it. Preserve trailing whitespace to avoid joining
// with the interpolation.
//
// For example:
// `padding: 0 ${PADDING}px`
// ^ this space should be preserved to avoid it becomes
// `padding:0${PADDING}px`
// Preserve this space character.
// Examples:
// - `padding: 0 ${VALUE}px` - space before interpolation preserved
// - `${A} ${B}` - space between interpolations preserved
// - `.class :hover` - space before pseudo-selector preserved
output.push(b' ');
}
continue;
Expand Down
7 changes: 5 additions & 2 deletions tasks/transform_conformance/snapshots/oxc.snap.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
commit: 41d96516

Passed: 183/305
Passed: 183/306

# All Passed:
* babel-plugin-transform-class-static-block
Expand Down Expand Up @@ -1612,7 +1612,7 @@ after transform: ["Function", "babelHelpers"]
rebuilt : ["babelHelpers", "dec"]


# plugin-styled-components (21/35)
# plugin-styled-components (21/36)
* styled-components/add-identifier-with-top-level-import-paths/input.js
x Output mismatch

Expand All @@ -1634,6 +1634,9 @@ x Output mismatch
* styled-components/does-not-replace-native-with-no-tags/input.js
x Output mismatch

* styled-components/minify-single-line-comments-with-interpolations/input.js
x Output mismatch

* styled-components/pre-transpiled/input.js
x Output mismatch

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import styled from 'styled-components';

// Test case from issue #13312
const StyledRemoveButton = styled.button``;
const SidebarDragHandle = styled.span``;
const RightSideWrapper = styled.div``;

const Wrapper = styled("div").withConfig({
displayName: "main__Wrapper",
})`
position: relative;
display: block;
${StyledRemoveButton} {
opacity: 0;
}
border: 1px black solid;
@media (hover: hover) {
&:hover ${SidebarDragHandle} {
display: inline-block;
}
&:hover ${RightSideWrapper} ${StyledRemoveButton} {
opacity: 0;
}
&:hover
${StyledRemoveButton},&
${RightSideWrapper}:hover
${StyledRemoveButton} {
opacity: 1;
}
}
`;

// Additional test cases for consecutive interpolations
const TestConsecutive = styled.div`
${StyledRemoveButton} ${SidebarDragHandle} {
color: red;
}
.class ${StyledRemoveButton} ${SidebarDragHandle} ${RightSideWrapper} {
color: blue;
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"plugins": [
[
"styled-components",
{
"minify": true,
"ssr": false,
"displayName": false,
"transpileTemplateLiterals": false
}
]
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import styled from 'styled-components';
const StyledRemoveButton = styled.button``;
const SidebarDragHandle = styled.span``;
const RightSideWrapper = styled.div``;
const Wrapper = styled("div").withConfig({
displayName: "main__Wrapper"
})`position:relative;display:block;${StyledRemoveButton}{opacity:0;}border:1px black solid;@media (hover:hover){&:hover ${SidebarDragHandle}{display:inline-block;}&:hover ${RightSideWrapper} ${StyledRemoveButton}{opacity:0;}&:hover ${StyledRemoveButton},& ${RightSideWrapper}:hover ${StyledRemoveButton}{opacity:1;}}`;
const TestConsecutive = styled.div`${StyledRemoveButton} ${SidebarDragHandle}{color:red;}.class ${StyledRemoveButton} ${SidebarDragHandle} ${RightSideWrapper}{color:blue;}`;
Loading