Repository navigation
fix(css-prop): place generated components after the values their css reads - #432
Conversation
🦋 Changeset detectedLatest commit: 969dfbd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
There was a problem hiding this comment.
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.bodypath. - Dependency scan (
cssDependencyStatements) walksReferencedIdentifierover the css attribute value, skippingmodule/hoistedbindings 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,
componentIdcounters 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`.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.
There was a problem hiding this comment.
✅ 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):cssDependencyStatementsnow enters aFunctionvisitor and callsskip(), 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 aftersizebut 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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Fixes #426
Using the
cssprop on a component defined in the same file, likeconst { Paragraph } = Typography, could crash the module on load withReferenceError: Cannot access 'paragraphCss' before initialization. The plugin placed the generated styled component right afterParagraph, 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.
Before,
var _StyledParagraph = …(paragraphCss)landed betweenParagraphandparagraphCss. Now it lands afterparagraphCss.What stays the same
<div css>) and imported components were already placed at the end of the file and are untouched.${() => 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.conststatement 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
@babel/preset-env.node scripts/compat.jsmatches main on styled-components v5, v6, and the v7 prerelease.pnpm benchshows no difference beyond run-to-run noise.Thank you @llimllib for the clear report and diagnosis!