diff --git a/.changeset/feat-css-prop-import-path.md b/.changeset/feat-css-prop-import-path.md new file mode 100644 index 0000000..fd10c7d --- /dev/null +++ b/.changeset/feat-css-prop-import-path.md @@ -0,0 +1,5 @@ +--- +'babel-plugin-styled-components': minor +--- + +Add a `cssPropImportPath` option to control which package the css-prop transform auto-imports `styled` from when the file has no existing styled import. Defaults to `'styled-components'` (existing behavior). React Native targets can set it to `'styled-components/native'` so the auto-injected import resolves to the right runtime. diff --git a/.changeset/feat-follow-local-alias-of-styled.md b/.changeset/feat-follow-local-alias-of-styled.md new file mode 100644 index 0000000..f63f14a --- /dev/null +++ b/.changeset/feat-follow-local-alias-of-styled.md @@ -0,0 +1,5 @@ +--- +'babel-plugin-styled-components': minor +--- + +Detect styled declarations that go through a local alias of the import, including the TypeScript theme-typing pattern `const styled = baseStyled as ThemedStyledInterface`. After type-stripping Babel sees a plain `const styled = baseStyled`, and the detector now follows single-identifier alias chains so `styled.div` resolves back to the original import. diff --git a/.changeset/fix-css-prop-object-key-shadowed-by-binding.md b/.changeset/fix-css-prop-object-key-shadowed-by-binding.md new file mode 100644 index 0000000..d6fff03 --- /dev/null +++ b/.changeset/fix-css-prop-object-key-shadowed-by-binding.md @@ -0,0 +1,5 @@ +--- +'babel-plugin-styled-components': patch +--- + +Fix invalid output when a `css={{ ... }}` object key matches a local binding name (e.g. `({ position }) =>
`). The reducer no longer mis-treats non-computed property names as scope references, so plain keys stay literal while only computed `[expr]` keys are extracted as prop interpolations. diff --git a/.changeset/fix-recognize-ts-import-default-helper.md b/.changeset/fix-recognize-ts-import-default-helper.md new file mode 100644 index 0000000..3a9fce9 --- /dev/null +++ b/.changeset/fix-recognize-ts-import-default-helper.md @@ -0,0 +1,5 @@ +--- +'babel-plugin-styled-components': patch +--- + +Recognize TypeScript's `__importDefault` interop helper alongside Babel's `_interopRequireDefault`. Files compiled through `tsc` / `ts-jest` (which emit `var sc_1 = __importDefault(require('styled-components'))`) now flow into the same detection path as Babel-compiled output, so styled declarations downstream pick up `displayName` and `componentId` as expected. diff --git a/README.md b/README.md index 0312d78..9ddd369 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ This plugin is a highly recommended supplement to the base styled-components lib - better debugging through automatic annotation of your styled components based on their context in the file system, etc. - various types of minification for styles and the tagged template literals styled-components uses +## Requirements + +This plugin is tested against: + +- `@babel/core` `^7` +- `styled-components` `>= 6` (earlier majors may still work but aren't exercised in CI) + ## Quick start Install the plugin first: @@ -22,6 +29,13 @@ Then add it to your babel configuration: } ``` +## Options + +Full option reference lives on the [styled-components documentation site](https://www.styled-components.com/docs/tooling#babel-plugin). A couple worth flagging here: + +- `topLevelImportPaths` (`string[]`): additional module specifiers whose `styled` export should be recognized alongside `styled-components`. Useful for libraries that re-export the styled-components API. +- `cssPropImportPath` (`string`, default `'styled-components'`): which package the css-prop transform should auto-import `styled` from when the file doesn't already have a styled import. Set to `'styled-components/native'` for React Native targets. + ## Changelog See [Github Releases](https://github.com/styled-components/babel-plugin-styled-components/releases) diff --git a/src/utils/detectors.js b/src/utils/detectors.js index 863e6ae..b9105d8 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -75,6 +75,37 @@ export const importLocalName = (name, state, options = {}) => { return localName } +// Follow `const X = Y` (and TS `const X = Y as Type`) chains so a local +// re-binding of the styled import still resolves to it. Lazy: only walks +// the chain when the direct-name check misses, so the hot path is unchanged. +const resolvesToDefaultLocal = (name, defaultLocal, state, t) => { + if (!name || !defaultLocal) return false + if (name === defaultLocal) return true + const scope = state.file.path.scope + const visited = new Set([name]) + let current = name + while (true) { + const binding = scope.getBinding(current) + if (!binding || !binding.path.isVariableDeclarator()) return false + const init = binding.path.node.init + if (!init) return false + let nextName = null + if (t.isIdentifier(init)) { + nextName = init.name + } else if ( + (init.type === 'TSAsExpression' || init.type === 'TSTypeAssertion') && + t.isIdentifier(init.expression) + ) { + nextName = init.expression.name + } + if (!nextName) return false + if (nextName === defaultLocal) return true + if (visited.has(nextName)) return false + visited.add(nextName) + current = nextName + } +} + export const isStyled = t => (tag, state) => { if ( t.isCallExpression(tag) && @@ -91,14 +122,16 @@ export const isStyled = t => (tag, state) => { return isStyled(t)(getSequenceExpressionValue(tag.callee), state) } else { const defaultLocal = importLocalName('default', state) + const matchesDefault = name => + resolvesToDefaultLocal(name, defaultLocal, state, t) return ( (t.isMemberExpression(tag) && - tag.object.name === defaultLocal && + matchesDefault(tag.object.name) && !isHelper(t)(tag.property, state)) || - (t.isCallExpression(tag) && tag.callee.name === defaultLocal) || + (t.isCallExpression(tag) && matchesDefault(tag.callee.name)) || (t.isCallExpression(tag) && t.isSequenceExpression(tag.callee) && - getSequenceExpressionValue(tag.callee).name === defaultLocal) || + matchesDefault(getSequenceExpressionValue(tag.callee).name)) || // styled.default.div``, styled.default.something() — require() forms (state.styledRequired && t.isMemberExpression(tag) && diff --git a/src/utils/options.js b/src/utils/options.js index 57eb483..cd607a2 100644 --- a/src/utils/options.js +++ b/src/utils/options.js @@ -27,3 +27,6 @@ export const useNamespace = state => { export const usePureAnnotation = state => getOption(state, 'pure', false) export const useCssProp = state => getOption(state, 'cssProp', true) + +export const useCssPropImportPath = state => + getOption(state, 'cssPropImportPath', 'styled-components') diff --git a/src/visitors/assignStyledRequired.js b/src/visitors/assignStyledRequired.js index a579fbe..c6bbcef 100644 --- a/src/visitors/assignStyledRequired.js +++ b/src/visitors/assignStyledRequired.js @@ -6,11 +6,13 @@ export default t => (path, state) => { if ( t.isCallExpression(path.node.init) && t.isIdentifier(path.node.init.callee) && - init.callee.name === '_interopRequireDefault' && + // `_interopRequireDefault` is Babel's CJS interop helper; `__importDefault` + // is TypeScript's. Both wrap a require() so the caller can reach `.default`. + (init.callee.name === '_interopRequireDefault' || + init.callee.name === '__importDefault') && init.arguments && init.arguments[0] ) { - // _interopRequireDefault(require()) init = path.node.init.arguments[0]; } diff --git a/src/visitors/transpileCssProp.js b/src/visitors/transpileCssProp.js index ebcf3a4..aeceaa6 100644 --- a/src/visitors/transpileCssProp.js +++ b/src/visitors/transpileCssProp.js @@ -1,7 +1,7 @@ // Most of this code was taken from @satya164's babel-plugin-css-prop import { addDefault } from '@babel/helper-module-imports' import { importLocalName } from '../utils/detectors' -import { useCssProp } from '../utils/options' +import { useCssProp, useCssPropImportPath } from '../utils/options' import { processCallExpression, processTaggedTemplate } from './process' const TAG_NAME_REGEXP = /^[a-z][a-z\d]*(\-[a-z][a-z\d]*)?$/ @@ -68,7 +68,7 @@ export default t => { // not directly callable, so treat it the same as "no default binding" and // inject a fresh default import to use as the css-prop callee. if (!importBinding || importBindingIsNamespace) { - addDefault(path, 'styled-components', { + addDefault(path, useCssPropImportPath(state), { nameHint: 'styled', }) @@ -174,8 +174,12 @@ export default t => { if ( t.isMemberExpression(property.key) || t.isCallExpression(property.key) || - // checking for css={{[something]: something}} + // checking for css={{[something]: something}}; a plain (non-computed) + // identifier key is a literal property name and never resolves to a + // binding, even if the local scope happens to define a same-named + // variable. Only computed keys reference the surrounding scope. (t.isIdentifier(property.key) && + property.computed && path.scope.hasBinding(property.key.name) && // but not a object reference shorthand like css={{ color }} (t.isIdentifier(property.value) diff --git a/test/fixtures/css-prop-object-key-shadowed-by-binding/.babelrc b/test/fixtures/css-prop-object-key-shadowed-by-binding/.babelrc new file mode 100644 index 0000000..94bf9ef --- /dev/null +++ b/test/fixtures/css-prop-object-key-shadowed-by-binding/.babelrc @@ -0,0 +1,12 @@ +{ + "plugins": [ + [ + "../../../src", + { + "ssr": false, + "fileName": false, + "transpileTemplateLiterals": false + } + ] + ] +} diff --git a/test/fixtures/css-prop-object-key-shadowed-by-binding/code.js b/test/fixtures/css-prop-object-key-shadowed-by-binding/code.js new file mode 100644 index 0000000..9074cfb --- /dev/null +++ b/test/fixtures/css-prop-object-key-shadowed-by-binding/code.js @@ -0,0 +1,9 @@ +// Regression: a non-computed object key (e.g. `position`) is a literal +// property name, never a binding reference, even when the local scope +// has a same-named binding. The css-prop object reducer must only rewrite +// computed keys (`[expr]`) into prop interpolations; plain keys stay literal. +import React from 'react' + +const Outer = ({ position }) => ( +
+) diff --git a/test/fixtures/css-prop-object-key-shadowed-by-binding/output.js b/test/fixtures/css-prop-object-key-shadowed-by-binding/output.js new file mode 100644 index 0000000..7c020c7 --- /dev/null +++ b/test/fixtures/css-prop-object-key-shadowed-by-binding/output.js @@ -0,0 +1,15 @@ +import _styled from "styled-components"; +// Regression: a non-computed object key (e.g. `position`) is a literal +// property name, never a binding reference, even when the local scope +// has a same-named binding. The css-prop object reducer must only rewrite +// computed keys (`[expr]`) into prop interpolations; plain keys stay literal. +import React from 'react'; +const Outer = ({ + position +}) => <_StyledDiv $_css={position} />; +var _StyledDiv = _styled("div").withConfig({ + displayName: "_StyledDiv" +})(p => ({ + position: 'absolute', + top: p.$_css +})); diff --git a/test/fixtures/css-prop-with-native-import-path/.babelrc b/test/fixtures/css-prop-with-native-import-path/.babelrc new file mode 100644 index 0000000..81f9293 --- /dev/null +++ b/test/fixtures/css-prop-with-native-import-path/.babelrc @@ -0,0 +1,13 @@ +{ + "plugins": [ + [ + "../../../src", + { + "ssr": false, + "fileName": false, + "transpileTemplateLiterals": false, + "cssPropImportPath": "styled-components/native" + } + ] + ] +} diff --git a/test/fixtures/css-prop-with-native-import-path/code.js b/test/fixtures/css-prop-with-native-import-path/code.js new file mode 100644 index 0000000..cfb6d5e --- /dev/null +++ b/test/fixtures/css-prop-with-native-import-path/code.js @@ -0,0 +1,11 @@ +// React Native targets need the auto-injected default import to come from +// `styled-components/native` rather than the DOM package. The +// `cssPropImportPath` option lets the consumer name the package; the rest +// of the css-prop transform is shape-compatible with RN already (the +// PascalCase JSX name routes through the identifier branch and produces +// `styled(View)` instead of `styled('View')`). +import { View } from 'react-native' + +const Comp = () => + +export default Comp diff --git a/test/fixtures/css-prop-with-native-import-path/output.js b/test/fixtures/css-prop-with-native-import-path/output.js new file mode 100644 index 0000000..3f183c5 --- /dev/null +++ b/test/fixtures/css-prop-with-native-import-path/output.js @@ -0,0 +1,15 @@ +import _styled from "styled-components/native"; +// React Native targets need the auto-injected default import to come from +// `styled-components/native` rather than the DOM package. The +// `cssPropImportPath` option lets the consumer name the package; the rest +// of the css-prop transform is shape-compatible with RN already (the +// PascalCase JSX name routes through the identifier branch and produces +// `styled(View)` instead of `styled('View')`). +import { View } from 'react-native'; +const Comp = () => <_StyledView />; +export default Comp; +var _StyledView = _styled(View).withConfig({ + displayName: "_StyledView" +})({ + backgroundColor: 'red' +}); diff --git a/test/fixtures/named-import-styled-from-sc-v6/.babelrc b/test/fixtures/named-import-styled-from-sc-v6/.babelrc new file mode 100644 index 0000000..94bf9ef --- /dev/null +++ b/test/fixtures/named-import-styled-from-sc-v6/.babelrc @@ -0,0 +1,12 @@ +{ + "plugins": [ + [ + "../../../src", + { + "ssr": false, + "fileName": false, + "transpileTemplateLiterals": false + } + ] + ] +} diff --git a/test/fixtures/named-import-styled-from-sc-v6/code.js b/test/fixtures/named-import-styled-from-sc-v6/code.js new file mode 100644 index 0000000..7d201a9 --- /dev/null +++ b/test/fixtures/named-import-styled-from-sc-v6/code.js @@ -0,0 +1,13 @@ +// Regression: styled-components v6 exposes a named `styled` export. The +// detector resolves `import { styled } from 'styled-components'` to the same +// local binding the default import would produce, so member and call forms +// both pick up displayName and componentId. +import { styled } from 'styled-components' + +const Foo = styled.div` + color: red; +` + +const Bar = styled('span')` + color: blue; +` diff --git a/test/fixtures/named-import-styled-from-sc-v6/output.js b/test/fixtures/named-import-styled-from-sc-v6/output.js new file mode 100644 index 0000000..279f786 --- /dev/null +++ b/test/fixtures/named-import-styled-from-sc-v6/output.js @@ -0,0 +1,11 @@ +// Regression: styled-components v6 exposes a named `styled` export. The +// detector resolves `import { styled } from 'styled-components'` to the same +// local binding the default import would produce, so member and call forms +// both pick up displayName and componentId. +import { styled } from 'styled-components'; +const Foo = styled.div.withConfig({ + displayName: "Foo" +})`color:red;`; +const Bar = styled('span').withConfig({ + displayName: "Bar" +})`color:blue;`; diff --git a/test/fixtures/pre-transpiled-tsc-importdefault/.babelrc b/test/fixtures/pre-transpiled-tsc-importdefault/.babelrc new file mode 100644 index 0000000..3653efe --- /dev/null +++ b/test/fixtures/pre-transpiled-tsc-importdefault/.babelrc @@ -0,0 +1,11 @@ +{ + "plugins": [ + [ + "../../../src", + { + "fileName": false, + "transpileTemplateLiterals": false + } + ] + ] +} diff --git a/test/fixtures/pre-transpiled-tsc-importdefault/code.js b/test/fixtures/pre-transpiled-tsc-importdefault/code.js new file mode 100644 index 0000000..211d54c --- /dev/null +++ b/test/fixtures/pre-transpiled-tsc-importdefault/code.js @@ -0,0 +1,16 @@ +// Regression: the TypeScript compiler emits `__importDefault` as its CommonJS +// interop helper (Babel uses `_interopRequireDefault`). Both wrap a require() +// to expose `.default`, and the plugin needs to recognize either form so that +// downstream styled-component detection on `.default.div` succeeds. +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +var styled_components_1 = __importDefault(require("styled-components")); +var Foo = styled_components_1.default.div` + color: red; +`; +var Bar = (0, styled_components_1.default)('span')` + color: blue; +`; diff --git a/test/fixtures/pre-transpiled-tsc-importdefault/output.js b/test/fixtures/pre-transpiled-tsc-importdefault/output.js new file mode 100644 index 0000000..088d3e9 --- /dev/null +++ b/test/fixtures/pre-transpiled-tsc-importdefault/output.js @@ -0,0 +1,23 @@ +// Regression: the TypeScript compiler emits `__importDefault` as its CommonJS +// interop helper (Babel uses `_interopRequireDefault`). Both wrap a require() +// to expose `.default`, and the plugin needs to recognize either form so that +// downstream styled-component detection on `.default.div` succeeds. +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var __importDefault = this && this.__importDefault || function (mod) { + return mod && mod.__esModule ? mod : { + "default": mod + }; +}; +var styled_components_1 = __importDefault(require("styled-components")); +var Foo = styled_components_1.default.div.withConfig({ + displayName: "Foo", + componentId: "sc-1km53of-0" +})`color:red;`; +var Bar = (0, styled_components_1.default)('span').withConfig({ + displayName: "Bar", + componentId: "sc-1km53of-1" +})`color:blue;`; diff --git a/test/fixtures/styled-alias-via-local-binding/.babelrc b/test/fixtures/styled-alias-via-local-binding/.babelrc new file mode 100644 index 0000000..3653efe --- /dev/null +++ b/test/fixtures/styled-alias-via-local-binding/.babelrc @@ -0,0 +1,11 @@ +{ + "plugins": [ + [ + "../../../src", + { + "fileName": false, + "transpileTemplateLiterals": false + } + ] + ] +} diff --git a/test/fixtures/styled-alias-via-local-binding/code.js b/test/fixtures/styled-alias-via-local-binding/code.js new file mode 100644 index 0000000..44f6c6d --- /dev/null +++ b/test/fixtures/styled-alias-via-local-binding/code.js @@ -0,0 +1,16 @@ +// Regression: TypeScript theme-typing patterns re-bind the default import +// through a local `const`, e.g. `const styled = baseStyled as ThemedSC<...>`. +// After type-stripping, Babel sees a plain `const styled = baseStyled`. +// The detector must follow such single-identifier aliases so `styled.div` is +// still recognized as a styled-component declaration. +import baseStyled from 'styled-components' + +const styled = baseStyled + +const Foo = styled.div` + color: red; +` + +const Bar = styled('span')` + color: blue; +` diff --git a/test/fixtures/styled-alias-via-local-binding/output.js b/test/fixtures/styled-alias-via-local-binding/output.js new file mode 100644 index 0000000..89b7311 --- /dev/null +++ b/test/fixtures/styled-alias-via-local-binding/output.js @@ -0,0 +1,15 @@ +// Regression: TypeScript theme-typing patterns re-bind the default import +// through a local `const`, e.g. `const styled = baseStyled as ThemedSC<...>`. +// After type-stripping, Babel sees a plain `const styled = baseStyled`. +// The detector must follow such single-identifier aliases so `styled.div` is +// still recognized as a styled-component declaration. +import baseStyled from 'styled-components'; +const styled = baseStyled; +const Foo = styled.div.withConfig({ + displayName: "Foo", + componentId: "sc-ep8j7b-0" +})`color:red;`; +const Bar = styled('span').withConfig({ + displayName: "Bar", + componentId: "sc-ep8j7b-1" +})`color:blue;`; diff --git a/test/fixtures/withconfig-chained-on-existing-config/.babelrc b/test/fixtures/withconfig-chained-on-existing-config/.babelrc new file mode 100644 index 0000000..fa2d595 --- /dev/null +++ b/test/fixtures/withconfig-chained-on-existing-config/.babelrc @@ -0,0 +1,12 @@ +{ + "plugins": [ + [ + "../../../src", + { + "ssr": true, + "fileName": false, + "transpileTemplateLiterals": false + } + ] + ] +} diff --git a/test/fixtures/withconfig-chained-on-existing-config/code.js b/test/fixtures/withconfig-chained-on-existing-config/code.js new file mode 100644 index 0000000..13f2855 --- /dev/null +++ b/test/fixtures/withconfig-chained-on-existing-config/code.js @@ -0,0 +1,11 @@ +// Regression: when the user already chains a `.withConfig(...)` with a +// non-literal argument (e.g. `withConfig(getConfig())`), the plugin appends +// its own `.withConfig({ displayName, componentId })` rather than skipping +// augmentation altogether. +import styled from 'styled-components' + +const getConfig = () => ({ shouldForwardProp: () => true }) + +const Foo = styled.div.withConfig(getConfig())` + color: red; +` diff --git a/test/fixtures/withconfig-chained-on-existing-config/output.js b/test/fixtures/withconfig-chained-on-existing-config/output.js new file mode 100644 index 0000000..08e9466 --- /dev/null +++ b/test/fixtures/withconfig-chained-on-existing-config/output.js @@ -0,0 +1,12 @@ +// Regression: when the user already chains a `.withConfig(...)` with a +// non-literal argument (e.g. `withConfig(getConfig())`), the plugin appends +// its own `.withConfig({ displayName, componentId })` rather than skipping +// augmentation altogether. +import styled from 'styled-components'; +const getConfig = () => ({ + shouldForwardProp: () => true +}); +const Foo = styled.div.withConfig(getConfig()).withConfig({ + displayName: "Foo", + componentId: "sc-1o89p33-0" +})`color:red;`;