From f4794b5bdc6a3f7bb795197016a23473d5586c5f Mon Sep 17 00:00:00 2001 From: Mitch Follett Date: Fri, 25 Sep 2026 09:52:27 +0100 Subject: [PATCH 1/2] fix(styled-components): keep locals in nested css prop member chains is_direct_access treated an interpolation as module-level when the chain's root was top-level and the outermost node had no local computed key. In `${SIZES[size].bottom}` the outermost node is `.bottom`, so the inner `[size]` was never checked and the expression was hoisted out of the component, where `size` is undefined. has_only_direct_keys now checks every computed key and call argument along the member/call chain. Fixes #659 Co-Authored-By: Claude Opus 5.5 (1M context) --- ...tyled-components-css-prop-nested-member.md | 5 ++ .../visitors/transpile_css_prop/transpile.rs | 46 +++++++++++++------ .../transpile-css-prop-nested-member/code.js | 11 +++++ .../config.json | 6 +++ .../output.js | 17 +++++++ 5 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-styled-components-css-prop-nested-member.md create mode 100644 packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/code.js create mode 100644 packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/config.json create mode 100644 packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/output.js diff --git a/.changeset/fix-styled-components-css-prop-nested-member.md b/.changeset/fix-styled-components-css-prop-nested-member.md new file mode 100644 index 000000000..c7a935c6f --- /dev/null +++ b/.changeset/fix-styled-components-css-prop-nested-member.md @@ -0,0 +1,5 @@ +--- +"@swc/plugin-styled-components": patch +--- + +Pass `css` prop interpolations such as `${SIZES[size].bottom}` through as props when a key further along the member chain is a local, instead of hoisting them out of the component's scope. diff --git a/packages/styled-components/transform/src/visitors/transpile_css_prop/transpile.rs b/packages/styled-components/transform/src/visitors/transpile_css_prop/transpile.rs index 12efcf778..9e07ba4bf 100644 --- a/packages/styled-components/transform/src/visitors/transpile_css_prop/transpile.rs +++ b/packages/styled-components/transform/src/visitors/transpile_css_prop/transpile.rs @@ -663,22 +663,38 @@ fn is_direct_access(expr: &Expr, is_top_level_ident: &F) -> bool where F: Fn(&Ident) -> bool, { - if let Some(root) = trace_root_value(expr) { - match root { - Expr::Lit(_) => true, - Expr::Ident(id) if is_top_level_ident(id) => match expr { - Expr::Call(CallExpr { args, .. }) => args + match trace_root_value(expr) { + Some(Expr::Lit(_)) => true, + Some(Expr::Ident(id)) if is_top_level_ident(id) => { + has_only_direct_keys(expr, is_top_level_ident) + } + _ => false, + } +} + +/// Whether every computed key and call argument along a member/call chain is +/// itself a direct access, so `SIZES[size].bottom` is not mistaken for a +/// module-level value. +fn has_only_direct_keys(expr: &Expr, is_top_level_ident: &F) -> bool +where + F: Fn(&Ident) -> bool, +{ + match expr { + Expr::Member(MemberExpr { obj, prop, .. }) => { + has_only_direct_keys(obj, is_top_level_ident) + && match prop { + MemberProp::Computed(ComputedPropName { expr, .. }) => { + is_direct_access(expr, is_top_level_ident) + } + _ => true, + } + } + Expr::Call(CallExpr { callee, args, .. }) => { + matches!(callee, Callee::Expr(callee) if has_only_direct_keys(callee, is_top_level_ident)) + && args .iter() - .all(|arg| -> bool { is_direct_access(&arg.expr, is_top_level_ident) }), - Expr::Member(MemberExpr { - prop: MemberProp::Computed(ComputedPropName { expr, .. }), - .. - }) => is_direct_access(expr, is_top_level_ident), - _ => true, - }, - _ => false, + .all(|arg| is_direct_access(&arg.expr, is_top_level_ident)) } - } else { - false + _ => true, } } diff --git a/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/code.js b/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/code.js new file mode 100644 index 000000000..0776e0e01 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/code.js @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +const Box = styled.div``; +const SIZES = { small: { bottom: '8px' }, large: { bottom: '16px' } }; +const KEY = 'small'; + +export const Local = ({ size }) => ; + +export const LocalCall = ({ size }) => ; + +export const TopLevel = () => ; diff --git a/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/config.json b/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/config.json new file mode 100644 index 000000000..e50b4bea8 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/config.json @@ -0,0 +1,6 @@ +{ + "ssr": false, + "displayName": false, + "transpileTemplateLiterals": false, + "minify": false +} diff --git a/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/output.js b/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/output.js new file mode 100644 index 000000000..80787d577 --- /dev/null +++ b/packages/styled-components/transform/tests/fixtures/transpile-css-prop-nested-member/output.js @@ -0,0 +1,17 @@ +import styled from 'styled-components'; +const Box = styled.div``; +var _StyledBox3 = styled(Box)`bottom: ${SIZES[KEY].bottom};`; +var _StyledBox2 = styled(Box)`bottom: ${(p)=>p.$_css2};`; +var _StyledBox = styled(Box)`bottom: ${(p)=>p.$_css};`; +const SIZES = { + small: { + bottom: '8px' + }, + large: { + bottom: '16px' + } +}; +const KEY = 'small'; +export const Local = ({ size })=><_StyledBox $_css={SIZES[size].bottom}/>; +export const LocalCall = ({ size })=><_StyledBox2 $_css2={SIZES.get(size).bottom}/>; +export const TopLevel = ()=><_StyledBox3/>; From 4ab1d16579e04d9255132e6a7d172751b8c9b248 Mon Sep 17 00:00:00 2001 From: Mitch Follett Date: Fri, 25 Sep 2026 10:22:09 +0100 Subject: [PATCH 2/2] test(styled-components): add wasm snapshot for nested css prop member Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/__snapshots__/wasm.test.ts.snap | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap b/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap index bfa04a087..0b700dc7e 100644 --- a/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap +++ b/packages/styled-components/__tests__/__snapshots__/wasm.test.ts.snap @@ -1292,6 +1292,31 @@ var _StyledDiv = styled("div").withConfig({ " `; +exports[`Should load styled-components wasm plugin correctly > Should transform transpile-css-prop-nested-member correctly 1`] = ` +"import styled from 'styled-components'; +const Box = styled.div\`\`; +var _StyledBox3 = styled(Box)\`bottom: \${SIZES[KEY].bottom};\`; +var _StyledBox2 = styled(Box)\`bottom: \${(p)=>p.$_css2};\`; +var _StyledBox = styled(Box)\`bottom: \${(p)=>p.$_css};\`; +const SIZES = { + small: { + bottom: '8px' + }, + large: { + bottom: '16px' + } +}; +const KEY = 'small'; +export const Local = ({ size })=>/*#__PURE__*/ React.createElement(_StyledBox, { + $_css: SIZES[size].bottom + }); +export const LocalCall = ({ size })=>/*#__PURE__*/ React.createElement(_StyledBox2, { + $_css2: SIZES.get(size).bottom + }); +export const TopLevel = ()=>/*#__PURE__*/ React.createElement(_StyledBox3, null); +" +`; + exports[`Should load styled-components wasm plugin correctly > Should transform transpile-require-default correctly 1`] = ` "const styled_default = require("styled-components"); const TestNormal = styled.div.withConfig({