What follows is my agent's diagnosis of the problem after it bit my team; I thought about rewriting it into my own words but I do not understand it well enough to improve it instead of make it worse.
I can confirm the simple reproduction steps below do in fact create invalid javascript though.
🤖 text follows from here:
Description
When the css prop is used on a component that is bound to a local variable (rather than imported directly), transpileCssProp injects the generated var _StyledX = styled(X)\...`` immediately after the component's declaration — without considering where the interpolated CSS values are declared.
If the interpolated css value is a module-scope const/let declared after the component binding, the generated var reads it inside its temporal dead zone, and the module throws at import time:
Uncaught ReferenceError: Cannot access 'paragraphCss' before initialization
The input source is valid — the const is declared before the component that uses it. The plugin reorders it into invalid code.
Reproduction
Minimal, no bundler involved:
// Demo.jsx
import { css } from 'styled-components';
import { Typography } from 'antd';
const { Paragraph } = Typography;
const paragraphCss = css`font-size: 14px;`;
export const Text = ({ children }) => <Paragraph css={paragraphCss}>{children}</Paragraph>;
babel.transformSync(src, {
filename: 'Demo.jsx',
presets: ['@babel/preset-react'],
plugins: ['babel-plugin-styled-components'], // default options
});
Actual output
import _styled, { css } from 'styled-components';
import { Typography } from 'antd';
const { Paragraph } = Typography;
var _StyledParagraph = _styled(Paragraph).withConfig({
displayName: "Demo___StyledParagraph",
componentId: "sc-1qu7org-0"
})(["", ""], paragraphCss); // <-- reads paragraphCss ...
const paragraphCss = css(["font-size:14px;"]); // <-- ... declared here
export const Text = ({ children }) => React.createElement(_StyledParagraph, null, children);
Evaluating that module throws ReferenceError: Cannot access 'paragraphCss' before initialization. (Confirmed independently of the styled-components runtime by evaluating just the emitted shape.)
Expected output
The generated var should be placed after everything it depends on — i.e. after const paragraphCss.
When it triggers
| Styled target |
Result |
const { Paragraph } = Typography (destructured local) |
throws |
const P = Typography.Paragraph (local variable) |
throws |
import { Paragraph } from 'antd' (direct import) |
ok |
<div css={...}> (HTML tag) |
ok |
It is also order-dependent: moving the css const above the component binding avoids it, because insertAfter(componentDecl) then happens to land after the const.
The two "ok" rows take the pushContainer('body', ...) path, which appends to the end of the module and is therefore always after the CSS declaration. Only the local-binding path uses insertAfter.
Cause
src/visitors/transpileCssProp.js (2.3.0, lib/ line 77):
styled = t.callExpression(importName, [nameExpression]);
if (bindings[name] && !t.isImportDeclaration(bindings[name].path.parent)) {
injector = nodeToInsert => (t.isVariableDeclaration(bindings[name].path.parent)
? bindings[name].path.parentPath
: bindings[name].path
).insertAfter(nodeToInsert);
}
The insertion point is derived solely from the component binding. The identifiers interpolated into the css value are not considered, so the injected node can precede their initialization.
This was introduced in #228, which fixed the opposite problem (#221, #203): appending to the end of the module broke when a bundler wrapped the component in an IIFE, so the node was moved to sit before the usage. That fix is correct in intent — the node just needs to also be after its own dependencies.
Suggested fix
Insert after the later of (a) the component binding and (b) the last module-scope binding referenced by the injected CSS expression. That keeps #228's guarantee (still before the usage site) while satisfying the dependencies.
I patched this locally as a proof of concept and verified:
Happy to open a PR with tests if the approach seems reasonable.
Environment
babel-plugin-styled-components: 2.3.0 (also reproduces on 2.1.4)
@babel/core: 7.x, @babel/preset-react
styled-components: 5.3.x
- Options: default (also reproduces with
{ displayName: true, fileName: true, ssr: false })
- Bundler: none required — reproduces with
babel.transformSync alone
Notes
This is easy to miss in practice because whether it throws depends on module/chunk ordering, so it can pass locally and only surface in a production bundle. In our case it shipped to a deployed environment and crashed a page on load; the same source built fine under a different bundler purely because module ordering happened to be favourable.
What follows is my agent's diagnosis of the problem after it bit my team; I thought about rewriting it into my own words but I do not understand it well enough to improve it instead of make it worse.
I can confirm the simple reproduction steps below do in fact create invalid javascript though.
🤖 text follows from here:
Description
When the
cssprop is used on a component that is bound to a local variable (rather than imported directly),transpileCssPropinjects the generatedvar _StyledX = styled(X)\...`` immediately after the component's declaration — without considering where the interpolated CSS values are declared.If the interpolated
cssvalue is a module-scopeconst/letdeclared after the component binding, the generatedvarreads it inside its temporal dead zone, and the module throws at import time:The input source is valid — the
constis declared before the component that uses it. The plugin reorders it into invalid code.Reproduction
Minimal, no bundler involved:
Actual output
Evaluating that module throws
ReferenceError: Cannot access 'paragraphCss' before initialization. (Confirmed independently of the styled-components runtime by evaluating just the emitted shape.)Expected output
The generated
varshould be placed after everything it depends on — i.e. afterconst paragraphCss.When it triggers
const { Paragraph } = Typography(destructured local)const P = Typography.Paragraph(local variable)import { Paragraph } from 'antd'(direct import)<div css={...}>(HTML tag)It is also order-dependent: moving the
cssconst above the component binding avoids it, becauseinsertAfter(componentDecl)then happens to land after the const.The two "ok" rows take the
pushContainer('body', ...)path, which appends to the end of the module and is therefore always after the CSS declaration. Only the local-binding path usesinsertAfter.Cause
src/visitors/transpileCssProp.js(2.3.0,lib/line 77):The insertion point is derived solely from the component binding. The identifiers interpolated into the
cssvalue are not considered, so the injected node can precede their initialization.This was introduced in #228, which fixed the opposite problem (#221, #203): appending to the end of the module broke when a bundler wrapped the component in an IIFE, so the node was moved to sit before the usage. That fix is correct in intent — the node just needs to also be after its own dependencies.
Suggested fix
Insert after the later of (a) the component binding and (b) the last module-scope binding referenced by the injected CSS expression. That keeps #228's guarantee (still before the usage site) while satisfying the dependencies.
I patched this locally as a proof of concept and verified:
component-first,css-first) are okvarlands after both the component and CSS declarations, and still before the usageHappy to open a PR with tests if the approach seems reasonable.
Environment
babel-plugin-styled-components: 2.3.0 (also reproduces on 2.1.4)@babel/core: 7.x,@babel/preset-reactstyled-components: 5.3.x{ displayName: true, fileName: true, ssr: false })babel.transformSyncaloneNotes
This is easy to miss in practice because whether it throws depends on module/chunk ordering, so it can pass locally and only surface in a production bundle. In our case it shipped to a deployed environment and crashed a page on load; the same source built fine under a different bundler purely because module ordering happened to be favourable.