diff --git a/packages/styled-jsx/src/css.ts b/packages/styled-jsx/src/css.ts index ae5c0d7..58b15a1 100644 --- a/packages/styled-jsx/src/css.ts +++ b/packages/styled-jsx/src/css.ts @@ -103,6 +103,15 @@ 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. @@ -110,8 +119,12 @@ function isGlobalPseudo(comp: SelectorComponent): boolean { * 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). @@ -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)`) 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) @@ -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)) } } } diff --git a/packages/styled-jsx/tests/fixtures/issue-94/input.jsx b/packages/styled-jsx/tests/fixtures/issue-94/input.jsx new file mode 100644 index 0000000..702f301 --- /dev/null +++ b/packages/styled-jsx/tests/fixtures/issue-94/input.jsx @@ -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; + } +` diff --git a/packages/styled-jsx/tests/fixtures/issue-94/output.jsx b/packages/styled-jsx/tests/fixtures/issue-94/output.jsx new file mode 100644 index 0000000..727b50d --- /dev/null +++ b/packages/styled-jsx/tests/fixtures/issue-94/output.jsx @@ -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 }; diff --git a/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx new file mode 100644 index 0000000..8c957fc --- /dev/null +++ b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx @@ -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; + } +` diff --git a/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx new file mode 100644 index 0000000..8c57a04 --- /dev/null +++ b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx @@ -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 };