Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/feat-css-prop-import-path.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/feat-follow-local-alias-of-styled.md
Original file line number Diff line number Diff line change
@@ -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<MyTheme>`. 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.
5 changes: 5 additions & 0 deletions .changeset/fix-css-prop-object-key-shadowed-by-binding.md
Original file line number Diff line number Diff line change
@@ -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 }) => <div css={{ position: 'absolute' }} />`). 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.
5 changes: 5 additions & 0 deletions .changeset/fix-recognize-ts-import-default-helper.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Comment thread
quantizor marked this conversation as resolved.
Expand Down
39 changes: 36 additions & 3 deletions src/utils/detectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand All @@ -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) &&
Expand Down
3 changes: 3 additions & 0 deletions src/utils/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
6 changes: 4 additions & 2 deletions src/visitors/assignStyledRequired.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

Expand Down
10 changes: 7 additions & 3 deletions src/visitors/transpileCssProp.js
Original file line number Diff line number Diff line change
@@ -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]*)?$/
Expand Down Expand Up @@ -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',
})

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/css-prop-object-key-shadowed-by-binding/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"plugins": [
[
"../../../src",
{
"ssr": false,
"fileName": false,
"transpileTemplateLiterals": false
}
]
]
}
9 changes: 9 additions & 0 deletions test/fixtures/css-prop-object-key-shadowed-by-binding/code.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div css={{ position: 'absolute', top: position }} />
)
15 changes: 15 additions & 0 deletions test/fixtures/css-prop-object-key-shadowed-by-binding/output.js
Original file line number Diff line number Diff line change
@@ -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
}));
13 changes: 13 additions & 0 deletions test/fixtures/css-prop-with-native-import-path/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"plugins": [
[
"../../../src",
{
"ssr": false,
"fileName": false,
"transpileTemplateLiterals": false,
"cssPropImportPath": "styled-components/native"
}
]
]
}
11 changes: 11 additions & 0 deletions test/fixtures/css-prop-with-native-import-path/code.js
Original file line number Diff line number Diff line change
@@ -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 = () => <View css={{ backgroundColor: 'red' }} />

export default Comp
15 changes: 15 additions & 0 deletions test/fixtures/css-prop-with-native-import-path/output.js
Original file line number Diff line number Diff line change
@@ -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'
});
12 changes: 12 additions & 0 deletions test/fixtures/named-import-styled-from-sc-v6/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"plugins": [
[
"../../../src",
{
"ssr": false,
"fileName": false,
"transpileTemplateLiterals": false
}
]
]
}
13 changes: 13 additions & 0 deletions test/fixtures/named-import-styled-from-sc-v6/code.js
Original file line number Diff line number Diff line change
@@ -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;
`
11 changes: 11 additions & 0 deletions test/fixtures/named-import-styled-from-sc-v6/output.js
Original file line number Diff line number Diff line change
@@ -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;`;
11 changes: 11 additions & 0 deletions test/fixtures/pre-transpiled-tsc-importdefault/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"plugins": [
[
"../../../src",
{
"fileName": false,
"transpileTemplateLiterals": false
}
]
]
}
16 changes: 16 additions & 0 deletions test/fixtures/pre-transpiled-tsc-importdefault/code.js
Original file line number Diff line number Diff line change
@@ -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 `<local>.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;
`;
23 changes: 23 additions & 0 deletions test/fixtures/pre-transpiled-tsc-importdefault/output.js
Original file line number Diff line number Diff line change
@@ -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 `<local>.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;`;
11 changes: 11 additions & 0 deletions test/fixtures/styled-alias-via-local-binding/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"plugins": [
[
"../../../src",
{
"fileName": false,
"transpileTemplateLiterals": false
}
]
]
}
16 changes: 16 additions & 0 deletions test/fixtures/styled-alias-via-local-binding/code.js
Original file line number Diff line number Diff line change
@@ -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;
`
15 changes: 15 additions & 0 deletions test/fixtures/styled-alias-via-local-binding/output.js
Original file line number Diff line number Diff line change
@@ -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;`;
12 changes: 12 additions & 0 deletions test/fixtures/withconfig-chained-on-existing-config/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"plugins": [
[
"../../../src",
{
"ssr": true,
"fileName": false,
"transpileTemplateLiterals": false
}
]
]
}
Loading
Loading