diff --git a/crates/oxc_transformer/src/plugins/styled_components.rs b/crates/oxc_transformer/src/plugins/styled_components.rs index ed7ea5b78df78..bb76ac3cfb953 100644 --- a/crates/oxc_transformer/src/plugins/styled_components.rs +++ b/crates/oxc_transformer/src/plugins/styled_components.rs @@ -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; diff --git a/tasks/transform_conformance/snapshots/oxc.snap.md b/tasks/transform_conformance/snapshots/oxc.snap.md index a8b971f4e6bbc..0eeec2fe5cb77 100644 --- a/tasks/transform_conformance/snapshots/oxc.snap.md +++ b/tasks/transform_conformance/snapshots/oxc.snap.md @@ -1,6 +1,6 @@ commit: 41d96516 -Passed: 183/305 +Passed: 183/306 # All Passed: * babel-plugin-transform-class-static-block @@ -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 @@ -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 diff --git a/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/input.js b/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/input.js new file mode 100644 index 0000000000000..32b3b0e87e4a3 --- /dev/null +++ b/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/input.js @@ -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; + } +`; diff --git a/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/options.json b/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/options.json new file mode 100644 index 0000000000000..ae19385a32725 --- /dev/null +++ b/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/options.json @@ -0,0 +1,13 @@ +{ + "plugins": [ + [ + "styled-components", + { + "minify": true, + "ssr": false, + "displayName": false, + "transpileTemplateLiterals": false + } + ] + ] +} diff --git a/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/output.js b/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/output.js new file mode 100644 index 0000000000000..f2061f3eeb35c --- /dev/null +++ b/tasks/transform_conformance/tests/plugin-styled-components/test/fixtures/minify-consecutive-interpolations/output.js @@ -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;}`;