Skip to content

css prop hoisting can cause a ReferenceError #426

Description

@llimllib

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions