Skip to content

fix(css-prop): place generated components after the values their css reads - #432

Merged
quantizor merged 2 commits into
mainfrom
fix/css-prop-tdz
Nov 3, 2026
Merged

quantizor merged 2 commits into
mainfrom
fix/css-prop-tdz

Conversation

@quantizor

@quantizor quantizor commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #426

Using the css prop on a component defined in the same file, like const { Paragraph } = Typography, could crash the module on load with ReferenceError: Cannot access 'paragraphCss' before initialization. The plugin placed the generated styled component right after Paragraph, even when the css it reads is declared further down.

The generated component now goes after both the component and every top-level value its css reads.

const { Paragraph } = Typography
const paragraphCss = css`font-size: 14px;`

export const Text = ({ children }) => (
  <Paragraph css={paragraphCss}>{children}</Paragraph>
)

Before, var _StyledParagraph = …(paragraphCss) landed between Paragraph and paragraphCss. Now it lands after paragraphCss.

What stays the same

  • Tags (<div css>) and imported components were already placed at the end of the file and are untouched.
  • Display names and component ids don't change, and no existing fixture output moved.
  • A function inside the css (${() => color}) reads its values only when it's called, so it never moves the generated component. Components used eagerly at the top level keep working.
  • One shape still can't be ordered: a css value declared in the same const statement as the component that uses it (const Text = () => <P css={c} />, c = css`…`). No placement fits between those two declarations, so that case keeps today's placement.

Testing

  • New fixtures cover destructured and member components; css declared before the component, after it, and after its use; the same-statement case; an eager top-level usage whose css function reads a later value; and one run through @babel/preset-env.
  • node scripts/compat.js matches main on styled-components v5, v6, and the v7 prerelease.
  • pnpm bench shows no difference beyond run-to-run noise.

Thank you @llimllib for the clear report and diagnosis!

@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 969dfbd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
babel-plugin-styled-components Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new anchor can move the generated declaration past the usage statement when the css value reads a later binding only lazily (for example an interpolated arrow). That turns a previously-working eager top-level usage into React.createElement(undefined).

Reviewed changes

  • Local-component placement rework (src/visitors/transpileCssProp.js): for a non-tag, non-import binding the injection anchor is now the later of the component's top-level statement and every top-level statement declaring a program-scope value the css value references; tags and imported components keep the end-of-Program.body path.
  • Dependency scan (cssDependencyStatements) walks ReferencedIdentifier over the css attribute value, skipping module/hoisted bindings and shadowed references.
  • Anchor selection (latestProgramStatement) reduces over those statements by key, excluding the usage statement itself.
  • Fixtures: seven new css-prop-local-component-* fixtures (destructured, member, dependency before/after the component, after the usage, same statement, and one through @babel/preset-env), plus a patch changeset.
  • Verified stable: existing snapshots unchanged, componentId counters do not shift, and the build/tests pass.

⚠️ Anchor can land after an eager top-level usage for a lazily-read dependency

cssDependencyStatements descends into nested function and arrow bodies, but those reads only happen when the interpolation is invoked, not when the generated declaration initializes. It therefore counts a later binding as a placement dependency and moves the anchor past an eager usage.

import { Typography } from 'antd'
const { Paragraph } = Typography
export const el = <Paragraph css={`color: ${() => color}`} />
const color = 'red'

main emitted var _StyledParagraph = ... before el (the arrow is lazy, so color was never a module-eval-time read) and this worked. This branch emits the var after const color, so el is built from an undefined _StyledParagraph (React.createElement(undefined)). The direct, non-lazy reference still has to move after its declaration for the dep-after-usage fixture to keep working, so the dependency set should exclude references reachable only through a nested function/arrow body. A fixture with an eager top-level usage plus a later lazily-read binding would catch this.

Technical details
# Lazy css interpolation counted as an initialization-order dependency

## Affected sites
- `src/visitors/transpileCssProp.js:57-70` — `cssDependencyStatements` records references found inside nested arrow/function bodies; those are only read when the interpolation is invoked.
- `src/visitors/transpileCssProp.js:78-88` — `latestProgramStatement` then moves the anchor to that later statement, which can sit after the JSX usage's top-level statement.

## Required outcome
- Do not treat a reference reachable only through a nested function/arrow body as an initialization-order dependency (or otherwise clamp the anchor before an eager usage).
- Keep the `dep-after-usage` fixture working: a direct reference must still land after its declaration.
- Add an eager top-level usage + later lazily-read binding fixture as a red test.

## Verification
Transformed the snippet with the built `main` visitor and the branch visitor: `main` put the injected `var` before `el`; the branch puts it after `const color`.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…ed components

A function interpolation reads its values only when called, so a later
binding it references must not move the generated declaration past an
eager top-level usage.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Incremental review of this PR; this run covers the follow-up commit that addressed the one regression flagged in the prior pullfrog review.

  • Skip function bodies in the css dependency scan (src/visitors/transpileCssProp.js): cssDependencyStatements now enters a Function visitor and calls skip(), so a reference reachable only through a nested arrow/function body (e.g. an interpolated () => color) no longer counts as an initialization-order dependency, and the generated declaration no longer moves past an eager usage that reads it.
  • Regression fixture (test/fixtures/css-prop-local-component-lazy-dep-after-eager-usage): an eager top-level usage whose css reads a direct later dependency (size) and a lazily-read later binding (color); the generated declaration now lands after size but before the usage.

Verified independently: building the e64ac09 visitor and the branch visitor and transforming the same input shows var _StyledParagraph landing after el pre-fix and before el post-fix, so the new fixture is a genuine red test. pnpm test (135 tests) and pnpm build pass.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@quantizor
quantizor merged commit 62f7cc1 into main Nov 3, 2026
6 checks passed
@quantizor
quantizor deleted the fix/css-prop-tdz branch November 3, 2026 04:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

css prop hoisting can cause a ReferenceError

1 participant