Skip to content
Open
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
62 changes: 46 additions & 16 deletions packages/styled-jsx/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,28 @@ function isGlobalPseudo(comp: SelectorComponent): boolean {
return comp.type === 'pseudo-class' && comp.kind === 'custom-function' && comp.name === 'global'
}

/**
* Check if a component is a `:scope` pseudo-class.
* `:scope` appears in the selector AST after lightningcss expands a top-level
* `&` nesting selector (CSS Nesting spec: bare `&` → `:scope` in a flat sheet).
*/
function isScopePseudo(comp: SelectorComponent): boolean {
return comp.type === 'pseudo-class' && comp.kind === 'scope'
}

/**
* Scope a selector by walking its components linearly, matching SWC's
* `get_transformed_selectors` approach.
*
* For each compound (segment between combinators):
* - If it contains `:global()`: unwrap the inner selector via
* `parseGlobalTokens` and inline the result (which may include
* combinators). No scope class is added.
* - Otherwise: insert `.jsx-{hash}` before any trailing pseudo-classes.
* combinators). Any leading `:scope` (from `&`) before `:global()`
* is also stripped. No scope class is added.
* - Otherwise: if the compound contains a `:scope` pseudo-class (from a
* top-level `&` via CSS nesting expansion), replace it in-place with
* `.jsx-{hash}`. Otherwise insert `.jsx-{hash}` before any trailing
* pseudo-classes/pseudo-elements.
*
* When `isGlobal` is true, only `:global()` unwrapping is performed
* (no scope class insertion).
Expand Down Expand Up @@ -147,9 +160,12 @@ function scopeSelector(
}

if (globalIdx >= 0) {
// Emit any components before :global() in this compound
// Emit any components before :global() in this compound, skipping any
// leading :scope (originated from `&` — e.g. `&:global(.foo)` → `:scope:global(.foo)`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's arguable that &:global(.foo) should be .jsx-hash.foo, but I think either is fine as &:global(.foo) is contradictory.

for (let j = 0; j < globalIdx; j++) {
result.push(compound[j])
if (!isScopePseudo(compound[j])) {
result.push(compound[j])
}
}

// Unwrap :global() — inline the parsed result (may include combinators)
Expand Down Expand Up @@ -177,23 +193,37 @@ function scopeSelector(
if (isGlobal) {
result.push(...compound)
} else {
// Lone pseudo-element (e.g. bare `::before`) — skip scoping, matching SWC
// Lone pseudo-element (e.g. bare `::before`) without a preceding `&`/:scope
// — skip scoping entirely, matching SWC
if (compound.length === 1 && compound[0].type === 'pseudo-element') {
result.push(compound[0])
} else {
// Find insertion point — before trailing pseudo-classes/pseudo-elements
let insertAt = compound.length
while (insertAt > 0) {
const prev = compound[insertAt - 1]
if (prev.type === 'pseudo-class' || prev.type === 'pseudo-element') {
insertAt--
} else {
break
// Replace any :scope pseudo-class (from a top-level `&`) with the scope
// class in place. e.g. `&:hover` → `:scope:hover` → `.jsx-HASH:hover`,
// `&::before` → `:scope::before` → `.jsx-HASH::before`,
// `& {}` → `:scope` → `.jsx-HASH`.
const scopeIdx = compound.findIndex(isScopePseudo)
if (scopeIdx >= 0) {
result.push(
...compound.slice(0, scopeIdx),
{ type: 'class', name: scopeClass } as SelectorComponent,
...compound.slice(scopeIdx + 1),
)
} else {
// No :scope — insert scope class before trailing pseudo-classes/pseudo-elements
let insertAt = compound.length
while (insertAt > 0) {
const prev = compound[insertAt - 1]
if (prev.type === 'pseudo-class' || prev.type === 'pseudo-element') {
insertAt--
} else {
break
}
}
result.push(...compound.slice(0, insertAt))
result.push({ type: 'class', name: scopeClass } as SelectorComponent)
result.push(...compound.slice(insertAt))
}
result.push(...compound.slice(0, insertAt))
result.push({ type: 'class', name: scopeClass } as SelectorComponent)
result.push(...compound.slice(insertAt))
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions packages/styled-jsx/tests/fixtures/issue-94/input.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import css from 'styled-jsx/css'

// & :global() — leading & should become scope class, not .jsx-HASH:scope
export const style1 = css`
& :global(.foo) {
display: flex;
}
`

// & .localClass — leading & should become scope class
export const style2 = css`
& .bar {
color: red;
}
`
7 changes: 7 additions & 0 deletions packages/styled-jsx/tests/fixtures/issue-94/output.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//#region virtual:entry.jsx
const style1 = /* @__PURE__ */ new String(".jsx-16e7d486c32249a4 .foo{display:flex}");
style1.__hash = "16e7d486c32249a4";
const style2 = /* @__PURE__ */ new String(".jsx-b58be9352957b283 .bar.jsx-b58be9352957b283{color:red}");
style2.__hash = "b58be9352957b283";
//#endregion
export { style1, style2 };
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import css from 'styled-jsx/css'

// Top-level & with additional class — &.foo should become .jsx-HASH.foo
export const s1 = css`
&.active {
color: blue;
}
`

// Top-level & with pseudo-class — &:hover should become .jsx-HASH:hover
export const s2 = css`
&:hover {
opacity: 0.8;
}
`

// Top-level & with pseudo-element — &::before should become .jsx-HASH::before
export const s3 = css`
&::before {
content: '';
}
`

// Top-level & with :not() — &:not(.foo) should become .jsx-HASH:not(.foo)
export const s4 = css`
&:not(.disabled) {
cursor: pointer;
}
`

// Top-level & with combined class + pseudo — &.foo:hover should become .jsx-HASH.foo:hover
export const s5 = css`
&.active:hover {
color: green;
}
`

// Top-level &:global(.foo) (no space) — :scope before :global should be stripped
export const s6 = css`
&:global(.theme-dark) {
background: black;
}
`

// Adjacent sibling via & — & + .sibling should work
export const s7 = css`
& + .sibling {
margin-left: 8px;
}
`

// Top-level & alone — already covered by issue-94 but verify
export const s8 = css`
& {
display: block;
}
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//#region virtual:entry.jsx
const s1 = /* @__PURE__ */ new String(".jsx-15793d61bc2fad0f.active{color:#00f}");
s1.__hash = "15793d61bc2fad0f";
const s2 = /* @__PURE__ */ new String(".jsx-440b5f13169269c4:hover{opacity:.8}");
s2.__hash = "440b5f13169269c4";
const s3 = /* @__PURE__ */ new String(".jsx-ef070750fd73693f:before{content:\"\"}");
s3.__hash = "ef070750fd73693f";
const s4 = /* @__PURE__ */ new String(".jsx-b0ec419eba2f5953:not(.disabled){cursor:pointer}");
s4.__hash = "b0ec419eba2f5953";
const s5 = /* @__PURE__ */ new String(".jsx-994a45b09028427d.active:hover{color:green}");
s5.__hash = "994a45b09028427d";
const s6 = /* @__PURE__ */ new String(".theme-dark{background:#000}");
s6.__hash = "b1ebcd8eee3f39f1";
const s7 = /* @__PURE__ */ new String(".jsx-a67efca2ae41cf84+.sibling.jsx-a67efca2ae41cf84{margin-left:8px}");
s7.__hash = "a67efca2ae41cf84";
const s8 = /* @__PURE__ */ new String(".jsx-98cce0331566d37e{display:block}");
s8.__hash = "98cce0331566d37e";
//#endregion
export { s1, s2, s3, s4, s5, s6, s7, s8 };
Loading