Skip to content

Commit 6462600

Browse files
skovhusclaude
andcommitted
fix(selector): bail on compound :has() selectors like &:has(${Icon}):hover
The loose HAS_COMPONENT_SELECTOR_RE regex matched any selector containing &:has(__SC_EXPR_N__), letting compound forms like &:has(${Icon}):hover bypass the interpolated-pseudo bailout. This caused them to fall through into the descendant-component handler, producing invalid output with unresolved placeholders. Now uses the strict anchored regex so only exact &:has(${Component}) selectors are handled; compound forms correctly bail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16f46bf commit 6462600

File tree

2 files changed

+37
-3
lines changed

2 files changed

+37
-3
lines changed

src/__tests__/transform.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5633,3 +5633,39 @@ export function App() {
56335633
expect(result.code).not.toMatch(/\{\.\.\.stylex\.props\([^)]+\)\}\s*className=/);
56345634
});
56355635
});
5636+
5637+
describe("compound :has() component selectors", () => {
5638+
it("should bail on &:has(${Component}):hover (compound pseudo + has)", () => {
5639+
const source = `
5640+
import styled from "styled-components";
5641+
5642+
const Icon = styled.span\`
5643+
color: blue;
5644+
\`;
5645+
5646+
const Button = styled.button\`
5647+
background: lightgray;
5648+
5649+
&:has(\${Icon}):hover {
5650+
background: lightyellow;
5651+
}
5652+
\`;
5653+
5654+
export const App = () => (
5655+
<div>
5656+
<Button>No icon</Button>
5657+
<Button>With icon <Icon>★</Icon></Button>
5658+
</div>
5659+
);
5660+
`;
5661+
5662+
const result = transformWithWarnings(
5663+
{ source, path: "test.tsx" },
5664+
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
5665+
{ adapter: fixtureAdapter },
5666+
);
5667+
5668+
// Should bail — compound :has + pseudo is not supported
5669+
expect(result.code).toBeNull();
5670+
});
5671+
});

src/internal/lower-rules/process-rules.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export function processDeclRules(ctx: DeclProcessingState): void {
248248
const hasInterpolatedPseudo = /:[^\s{]*__SC_EXPR_\d+__/.test(selectorForAnalysis);
249249
// &:has(${Component}) has a placeholder inside :has() — not an interpolated pseudo.
250250
// Skip the interpolated-pseudo handler so it reaches the component selector path.
251-
const isHasComponentSelector = HAS_COMPONENT_SELECTOR_RE.test(selectorForAnalysis);
251+
const isHasComponentSelector = HAS_COMPONENT_SELECTOR_STRICT_RE.test(selectorForAnalysis);
252252

253253
if (hasInterpolatedPseudo && !isHasComponentSelector) {
254254
// Handle interpolated pseudo selectors like `&:${highlight}`.
@@ -2283,8 +2283,6 @@ function extractCssTextFromNode(node: unknown): string | null {
22832283
return null;
22842284
}
22852285

2286-
/** Descendant-has pattern (substring match): `&:has(__SC_EXPR_N__)` anywhere in selector */
2287-
const HAS_COMPONENT_SELECTOR_RE = /&:has\(__SC_EXPR_\d+__\)/;
22882286
/** Descendant-has pattern (full selector match): exactly `&:has(__SC_EXPR_N__)` */
22892287
const HAS_COMPONENT_SELECTOR_STRICT_RE = /^&:has\(__SC_EXPR_\d+__\)\s*$/;
22902288

0 commit comments

Comments
 (0)