From 514fc5beeb3d94badd6b42c0abd21e7ec269ff6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:43:14 +0000 Subject: [PATCH 1/4] fix(styled-jsx): replace lone :scope with scope class to fix & :global(...) selectors --- packages/styled-jsx/src/css.ts | 8 ++++++++ .../styled-jsx/tests/fixtures/issue-94/input.jsx | 15 +++++++++++++++ .../styled-jsx/tests/fixtures/issue-94/output.jsx | 7 +++++++ 3 files changed, 30 insertions(+) create mode 100644 packages/styled-jsx/tests/fixtures/issue-94/input.jsx create mode 100644 packages/styled-jsx/tests/fixtures/issue-94/output.jsx diff --git a/packages/styled-jsx/src/css.ts b/packages/styled-jsx/src/css.ts index ae5c0d7..54e118d 100644 --- a/packages/styled-jsx/src/css.ts +++ b/packages/styled-jsx/src/css.ts @@ -180,6 +180,14 @@ function scopeSelector( // Lone pseudo-element (e.g. bare `::before`) — skip scoping, matching SWC if (compound.length === 1 && compound[0].type === 'pseudo-element') { result.push(compound[0]) + } else if ( + compound.length === 1 && + compound[0].type === 'pseudo-class' && + (compound[0] as { type: 'pseudo-class'; kind: string }).kind === 'scope' + ) { + // Lone :scope (originated from leading `&` via CSS nesting expansion) — + // replace with scope class instead of prepending to avoid `.jsx-HASH:scope` + result.push({ type: 'class', name: scopeClass } as SelectorComponent) } else { // Find insertion point — before trailing pseudo-classes/pseudo-elements let insertAt = compound.length 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 }; From 662d7a5d837d3a375c181db950e9810bc0b1e300 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:57:34 +0000 Subject: [PATCH 2/4] fix(styled-jsx): strip :scope from all compound selectors, not just lone :scope --- packages/styled-jsx/src/css.ts | 70 ++++++++++++------- .../scope-ampersand-patterns/input.jsx | 57 +++++++++++++++ .../scope-ampersand-patterns/output.jsx | 19 +++++ 3 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx create mode 100644 packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx diff --git a/packages/styled-jsx/src/css.ts b/packages/styled-jsx/src/css.ts index 54e118d..1749335 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 as { type: 'pseudo-class'; kind: string }).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: strip any `:scope` pseudo-class that originated from a + * top-level `&` via CSS nesting expansion (lightningcss expands a + * root-level `&` to `:scope`). After stripping, 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,31 +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 if ( - compound.length === 1 && - compound[0].type === 'pseudo-class' && - (compound[0] as { type: 'pseudo-class'; kind: string }).kind === 'scope' - ) { - // Lone :scope (originated from leading `&` via CSS nesting expansion) — - // replace with scope class instead of prepending to avoid `.jsx-HASH:scope` - result.push({ type: 'class', name: scopeClass } as SelectorComponent) } 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 + // Strip any :scope pseudo-class that originated from a top-level `&`. + // e.g. `&.foo` → `:scope.foo`, `&:hover` → `:scope:hover`, + // `&::before` → `:scope::before`, `& {}` → `:scope` + const hasScopeComponent = compound.some(isScopePseudo) + const effective = hasScopeComponent + ? compound.filter((c) => !isScopePseudo(c)) + : compound + + if (effective.length === 0) { + // Was a lone :scope (from bare `&`) — emit just the scope class + result.push({ type: 'class', name: scopeClass } as SelectorComponent) + } else { + // Insert scope class before trailing pseudo-classes/pseudo-elements + let insertAt = effective.length + while (insertAt > 0) { + const prev = effective[insertAt - 1] + if (prev.type === 'pseudo-class' || prev.type === 'pseudo-element') { + insertAt-- + } else { + break + } } + result.push(...effective.slice(0, insertAt)) + result.push({ type: 'class', name: scopeClass } as SelectorComponent) + result.push(...effective.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/scope-ampersand-patterns/input.jsx b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx new file mode 100644 index 0000000..7a45ecf --- /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 .foo.jsx-HASH +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 .foo.jsx-HASH: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..af43361 --- /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(".active.jsx-15793d61bc2fad0f{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(".active.jsx-994a45b09028427d: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 }; From 6005a77868a6e5b2165cf9076476f7607ec111db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:59:56 +0000 Subject: [PATCH 3/4] refactor(styled-jsx): remove redundant type assertion in isScopePseudo --- packages/styled-jsx/src/css.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styled-jsx/src/css.ts b/packages/styled-jsx/src/css.ts index 1749335..6150bd9 100644 --- a/packages/styled-jsx/src/css.ts +++ b/packages/styled-jsx/src/css.ts @@ -109,7 +109,7 @@ function isGlobalPseudo(comp: SelectorComponent): boolean { * `&` nesting selector (CSS Nesting spec: bare `&` → `:scope` in a flat sheet). */ function isScopePseudo(comp: SelectorComponent): boolean { - return comp.type === 'pseudo-class' && (comp as { type: 'pseudo-class'; kind: string }).kind === 'scope' + return comp.type === 'pseudo-class' && comp.kind === 'scope' } /** From b9150bab6e766f384635500619c6ff15f3883622 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:05:55 +0000 Subject: [PATCH 4/4] Replace &/:scope two-step with single in-place replacement in scopeSelector --- packages/styled-jsx/src/css.ts | 40 +++++++++---------- .../scope-ampersand-patterns/input.jsx | 4 +- .../scope-ampersand-patterns/output.jsx | 4 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/styled-jsx/src/css.ts b/packages/styled-jsx/src/css.ts index 6150bd9..58b15a1 100644 --- a/packages/styled-jsx/src/css.ts +++ b/packages/styled-jsx/src/css.ts @@ -121,10 +121,10 @@ function isScopePseudo(comp: SelectorComponent): boolean { * `parseGlobalTokens` and inline the result (which may include * combinators). Any leading `:scope` (from `&`) before `:global()` * is also stripped. No scope class is added. - * - Otherwise: strip any `:scope` pseudo-class that originated from a - * top-level `&` via CSS nesting expansion (lightningcss expands a - * root-level `&` to `:scope`). After stripping, insert `.jsx-{hash}` - * before any trailing pseudo-classes/pseudo-elements. + * - 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). @@ -198,31 +198,31 @@ function scopeSelector( if (compound.length === 1 && compound[0].type === 'pseudo-element') { result.push(compound[0]) } else { - // Strip any :scope pseudo-class that originated from a top-level `&`. - // e.g. `&.foo` → `:scope.foo`, `&:hover` → `:scope:hover`, - // `&::before` → `:scope::before`, `& {}` → `:scope` - const hasScopeComponent = compound.some(isScopePseudo) - const effective = hasScopeComponent - ? compound.filter((c) => !isScopePseudo(c)) - : compound - - if (effective.length === 0) { - // Was a lone :scope (from bare `&`) — emit just the scope class - result.push({ type: 'class', name: scopeClass } as SelectorComponent) + // 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 { - // Insert scope class before trailing pseudo-classes/pseudo-elements - let insertAt = effective.length + // No :scope — insert scope class before trailing pseudo-classes/pseudo-elements + let insertAt = compound.length while (insertAt > 0) { - const prev = effective[insertAt - 1] + const prev = compound[insertAt - 1] if (prev.type === 'pseudo-class' || prev.type === 'pseudo-element') { insertAt-- } else { break } } - result.push(...effective.slice(0, insertAt)) + result.push(...compound.slice(0, insertAt)) result.push({ type: 'class', name: scopeClass } as SelectorComponent) - result.push(...effective.slice(insertAt)) + result.push(...compound.slice(insertAt)) } } } diff --git a/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx index 7a45ecf..8c957fc 100644 --- a/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx +++ b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/input.jsx @@ -1,6 +1,6 @@ import css from 'styled-jsx/css' -// Top-level & with additional class — &.foo should become .foo.jsx-HASH +// Top-level & with additional class — &.foo should become .jsx-HASH.foo export const s1 = css` &.active { color: blue; @@ -28,7 +28,7 @@ export const s4 = css` } ` -// Top-level & with combined class + pseudo — &.foo:hover should become .foo.jsx-HASH:hover +// Top-level & with combined class + pseudo — &.foo:hover should become .jsx-HASH.foo:hover export const s5 = css` &.active:hover { color: green; diff --git a/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx index af43361..8c57a04 100644 --- a/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx +++ b/packages/styled-jsx/tests/fixtures/scope-ampersand-patterns/output.jsx @@ -1,5 +1,5 @@ //#region virtual:entry.jsx -const s1 = /* @__PURE__ */ new String(".active.jsx-15793d61bc2fad0f{color:#00f}"); +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"; @@ -7,7 +7,7 @@ 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(".active.jsx-994a45b09028427d:hover{color:green}"); +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";