Skip to content

fix: keep display names on unknown styled tags, and leave helpers alone - #430

Merged
quantizor merged 7 commits into
mainfrom
cursor/fix-require-nontag-exports-f8f9
Oct 22, 2026
Merged

quantizor merged 7 commits into
mainfrom
cursor/fix-require-nontag-exports-f8f9

Conversation

@quantizor

@quantizor quantizor commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

CommonJS files that call helpers on the required styled-components module crashed at runtime after #429. This follows up that change.

Take this file:

const sc = require('styled-components')
const A = sc.default.div`color: red;`
const ok = sc.isStyledComponent(A)

The plugin rewrote the helper into sc.isStyledComponent.withConfig(...)(A), which throws TypeError: sc.isStyledComponent.withConfig is not a function on styled-components v5, v6, and v7. createTheme, extractCSS, and the other named exports that are not tags did the same thing.

I left those calls alone. Tags still get a display name and component id, including sc.div on v7 (where require() returns the styled function with named exports attached) and sc.default.div on v5 and v6.

I also added a changelog note for helpers imported before styled, like import { css, styled }. They are recognized now, so their output is minified, and pure-annotated when that option is on. #429 shipped that without a note.

AGENTS.md now lists a review matrix for v5, v6, and the v7 prerelease. node scripts/compat.js runs it.

CommonJS files that call helpers on the required styled-components module crashed at runtime after #429. This follows up that change.

Take this file:

const sc = require('styled-components')
const A = sc.default.div`color: red;`
const ok = sc.isStyledComponent(A)

The plugin rewrote the helper into sc.isStyledComponent.withConfig(...)(A), which throws TypeError: sc.isStyledComponent.withConfig is not a function on styled-components v5, v6, and v7. createTheme, extractCSS, and the other named exports that are not tags did the same thing.

I left those calls alone. Tags still get a display name and component id, including sc.div on v7 (where require() returns the styled function with named exports attached) and sc.default.div on v5 and v6.

I also added a changelog note for helpers imported before styled, like import { css, styled }. They are recognized now, so their output is minified, and pure-annotated when that option is on. #429 shipped that without a note.

AGENTS.md now lists a review matrix for v5, v6, and the v7 prerelease. node scripts/compat.js runs it.

A binding that is the styled factory keeps a display name and component id for properties this plugin does not recognize. On styled-components v7 that factory is a proxy, so styled.box, styled.foo, and styled.feNotAFilter are real components. A fixed list of tag names was dropping those ids.

These are the factory, under any local name:

const styled = require('styled-components')
const sc = require('styled-components')
import sc from 'styled-components'
import { styled as s } from 'styled-components'
sc.default
ns.default
__importDefault(require('styled-components')).default

styled.box, sc.box, sc.default.foo, ns.box, and s.feNotAFilter are decorated. A namespace import uses the same member rules, because a CommonJS transform binds it to the module exports.

Known non-component exports stay plain, and so does any key that already exists on Object.prototype or Function.prototype (name in Object.prototype || name in Function.prototype). None of those names are DOM or SVG tags, so this does not drop a real component. These keep working on v5, v6, and v7:

sc.css`color: red;`
sc.keyframes`from { opacity: 0; } to { opacity: 1; }`
sc.createGlobalStyle`body { margin: 0; }`
sc['createGlobalStyle']`body { margin: 0; }`
sc.isStyledComponent(A)
sc.hasOwnProperty('css')
sc.toString()
sc.bind`x`
sc.__lookupGetter__`x`
sc.__proto__`x`
sc.default.css`color: red;`

A computed key is resolved when it is a string, a template whose parts all fold to strings, or a const initialized to one of those. const name = 'css'; sc[name], sc[`css`], and sc[`${'css'}`] stay plain. const css = 'div'; sc[css] and sc.default[css] stay decorated. A key this plugin cannot resolve stays plain on require() and on an alias that is not named styled, so let dynamic = 'css'; sc[dynamic] returns the helper instead of throwing. styled[dynamic] on an import whose local name is styled is still decorated, which matches the previous plugin.

A let or var that starts as the factory keeps its display name on a straight-line use written before the reassignment. A use inside a nested function, or inside a loop that also reassigns the binding, is left alone, so this does not throw on v7:

let sc = require('styled-components')
function writtenAbove() { return sc.span`color: blue;` }
sc = { span: s => () => null }
writtenAbove()

A loop that reads sc.div and then reassigns sc does not throw on the next iteration. The straight-line use before that loop keeps its id.

v5 native aliases such as WebView are covered by the same factory rule. import sc from 'styled-components/native' and require('styled-components/native') are the factory, so sc.WebView gets a display name with no separate alias list. v5 native ignores componentId, so the visible effect there is the DevTools name. There is no extra v5-only code.

Helpers imported before styled, like import { css, styled }, are still recognized. Their output is minified, and pure-annotated when that option is on. #429 shipped that without a note.

Compared with babel-plugin-styled-components 2.3.0 on styled-components 5.3.11, 6.6.0, and 7.0.0-prerelease-20261019010252, CommonJS and ESM.

The reviewer's attached harness is 104,213 cases. Regenerating that generator (the original export list, no extra proxy keys, 46 hand-written shapes) produces 104,213 again: 61,374 core, 1,245 named, 1,224 subpath, 46 shapes, 26,708 options, 866 css prop, 12,750 minify. The figure 108,913 came from that same generator after four proxy keys (box, foo, feNotAFilter, marquee) were added to the export list and three shapes were added. That generator is not in this repo, so 108,913 cannot be reproduced from the tree.

This run is the 104,213 harness plus 10 repros (closure and loop reassignment, unresolved and destructured keys, literal templates, prototype members, and styled[dynamic]). Each repro is also compiled under the 11 non-default option presets, which adds 120 cases. Total 104,333.

Of those, 63,902 compiled to the same code and were not re-executed, 37,447 differed and were executed, and 2,984 are compile-only. No new crash. No lost id.

Fixed crashes and gained ids, counted once per case across versions (a case that shows up on more than one version is one case): 5,782 fixed crashes, 3,315 gained ids.

The same 37,447 differed cases, counted inside each version on its own. These are not a total:

  • v5: 1,683 fixed, 818 gained ids
  • v6: 1,773 fixed, 1,270 gained ids
  • v7: 5,574 fixed, 3,315 gained ids

node scripts/compat.js runs the compact version of that matrix.

Open in Web Open in Cursor 

quantizor and others added 2 commits October 22, 2026 15:35
…yled tags

Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>
Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>
@changeset-bot

changeset-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5aa81ee

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

@quantizor
quantizor marked this pull request as ready for review October 22, 2026 15:43

@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 fix is right for sc.X, but the same TypeError is still reachable through sc.default.isStyledComponent(...), the namespace ns.default.isStyledComponent(...), and computed access sc['isStyledComponent']. Evidence and the uncovered arms are inline.

Reviewed changes

  • Non-tag export guard (src/utils/detectors.js): a new NON_TAG_EXPORTS set plus a guard on the first isStyled member arm, so sc.isStyledComponent(...), sc.createTheme(...), sc.extractCSS(), sc.toStyleSheet(), and sc.styled(...) on a require binding are no longer decorated as tags, while sc.div/sc.default.div still receive displayName and componentId. Helpers stay excluded through isHelper.
  • Fixtures: require-non-tag-exports and require-non-tag-exports-styled-name pin both the left-alone helper calls and the still-decorated tags.
  • Changesets: one note for the require-helper fix, one for helpers imported before styled.
  • Review matrix (scripts/compat.js, AGENTS.md): a dev-only v5/v6/v7 smoke runner and the documented review expectations.

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

@pullfrog

pullfrog Bot commented Oct 22, 2026

Copy link
Copy Markdown

The inline anchor from the review didn't attach, so the evidence is here.

The guard added at src/utils/detectors.js:229-231 only reaches the direct sc.X member arm. Two sibling arms of the same isStyled return expression are unguarded:

  • the state.styledRequired arm at src/utils/detectors.js:239-243, which matches sc.default.X; and
  • the isNamespaceDefault arm at src/utils/detectors.js:256-267, which matches ns.default.X and styled.default.X.

Computed access also sidesteps the guard, because t.isIdentifier(tag.property) is false for sc['isStyledComponent'].

Technical details
# Non-tag exports still decorated via .default and computed access

## Affected sites
- `src/utils/detectors.js:229-231` — the new `NON_TAG_EXPORTS` guard, applied only to the first member arm
- `src/utils/detectors.js:239-243` — `sc.default.X` arm (no guard)
- `src/utils/detectors.js:256-267` — `ns.default.X` / `styled.default.X` arm (no guard)
- `src/utils/detectors.js:230` — `t.isIdentifier(tag.property)` skips `sc['X']` (StringLiteral property)

## Evidence
Verified against the real v7 prerelease (`styled-components@test` = `7.0.0-prerelease-20261019010252`), where `require()` returns the callable styled with `sc.default === sc` and the named exports attached (`sc.default.isStyledComponent` is a function):

```js
// input
const sc = require('styled-components')
const A = sc.default.div`color: red;`
const ok = sc.default.isStyledComponent(A)

// plugin output (this branch)
const A = sc.default.div.withConfig({ displayName: "code__A", componentId: "sc-...-0" })(["color: red;"]);
const ok = sc.default.isStyledComponent.withConfig({ displayName: "code__ok", componentId: "sc-...-1" })(A);
// runtime: TypeError: sc.default.isStyledComponent.withConfig is not a function
```

The same holds for `ns.default.isStyledComponent(A)` under `import * as ns from 'styled-components'`, and `sc['isStyledComponent'](A)` compiles to `sc['isStyledComponent'].withConfig(...)(A)`. `require('styled-components').css` / `.keyframes` / `.createGlobalStyle` / `.withTheme` / `.useTheme` are correctly left alone via `isHelper`.

Note: on v5 and v6, `sc.default.isStyledComponent` is `undefined`, so that shape is already broken regardless of the plugin; the crash on well-formed v7 code is the reachable case. This root cause is present in the base (`bc47e42`) too, so it is not introduced here — but it is the same defect class this PR's changeset says is "left alone now".

## Required outcome
`sc.default.isStyledComponent(...)`, `ns.default.isStyledComponent(...)`, and `sc['isStyledComponent'](...)` must not receive `.withConfig`, while `sc.default.div` and `ns.default.div` still do.

## Suggested approach
Apply the same `NON_TAG_EXPORTS` membership check to the `tag.object.property.name === 'default'` arm (inspect `tag.property`) and the `isNamespaceDefault` arm, and normalize StringLiteral computed properties before the membership test (or accept the small extra by checking `tag.property.type === 'StringLiteral'`).

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

sc.default.isStyledComponent, namespace ns.default.isStyledComponent, and
computed sc['isStyledComponent'] / sc.default['createTheme'] were still
rewritten to .withConfig(). One property-name helper covers identifier
and string keys. Real tags in those forms stay decorated.

Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>

@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 guard closes the reported .default and computed non-tag gaps, but the helper exports (css, keyframes, createGlobalStyle, withTheme, useTheme) are still decorated through the same arms, so sc.default.css and sc['css'] keep throwing the TypeError this PR targets.

Reviewed changes

  • Extended the non-tag-export guard to the sc.default.X and ns.default.X arms and taught it to resolve computed string keys through memberPropertyName, closing the sc.default.isStyledComponent, ns.default.isStyledComponent, and sc['isStyledComponent'] cases from the prior review.
  • Added fixtures for namespace-default and computed helper calls next to still-decorated tags (sc['div'], sc.default['div']).
  • Extended scripts/compat.js with namespace-default and computed-access smoke cases, and broadened the changeset to describe the wider scope.

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

Comment thread src/utils/detectors.js Outdated
css, keyframes, createGlobalStyle, useTheme, and withTheme were still
rewritten to .withConfig() through sc.default.css and sc['css']. Those
names are non-tag exports, so every member arm leaves them alone.

Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>

@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

This run re-reviewed the delta since the prior pullfrog review at ffaa11e: commit 79ccd04, which closes the helper-export gap the last review flagged.

  • Helper exports added to the denylist (src/utils/detectors.js): css, keyframes, createGlobalStyle, withTheme, and useTheme now sit in NON_TAG_EXPORTS, so sc.default.css, sc['css'], sc.default['css'], ns.default.css, ns['css'], and their computed siblings no longer receive .withConfig. Confirmed by transform: these forms emit unchanged while sc.default['div'] still gets a displayName and componentId.
  • Fixture coverage (test/fixtures/require-non-tag-exports/, .../namespace-default-non-tag-exports/): added computed and .default helper calls next to the still-decorated tags, pinning both directions of the behavior.
  • Compat smoke cases (scripts/compat.js): added ns['css'], ns.default.css, sc['css'], sc.default.css, and sc['keyframes'] with matching plain markers that assert the exports are left alone on v5/v6/v7.
  • Changeset (.changeset/require-non-tag-exports.md): broadened to describe the helper-export scope.

All prior review feedback is addressed; the single prior thread is resolved. pnpm test passes (101) and pnpm build succeeds.

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

…ndings

Require, alias, and namespace bindings only get .withConfig for intrinsic
tags and styled() calls, including computed ['default']. A local named
styled still decorates unknown properties. Shadowed or reassigned locals
are resolved through the binding, and a computed variable key stays a
dynamic lookup.

Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>

@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

This run covers the delta since the prior pullfrog review at 79ccd04: commit 96da99b, which replaces the deny-list approach with scope-resolved binding kinds. I verified the new allowlist against the real runtimes, diffed compile output against main across ~400 shapes, and ran the test suite, coverage, and bench.

  • Binding resolution rewrote isStyled (src/utils/detectors.js): each identifier now resolves through Babel scope to open (import local named styled, decorates unknown members), allow (require(...) and aliased imports, decorates only allowlisted tags), namespace (import * as ns), or null (shadowed/reassigned/unrelated). This closes the previous deny-list gap where sc.somethingNew and sc.hasOwnProperty were still rewritten.
  • New intrinsic-tag allowlist (src/utils/domElements.js): STYLED_TAGS is consulted only for allow/namespace. It is a superset of the v5 (136) and v6 (153) web tags, matches the v6 native alias list exactly, and never collides with the module's named exports.
  • Require bindings gated by traversal order (noteStyledRequired / requireBindingName in assignStyledRequired.js): a require declarator is only trusted after the visitor has walked it, so the css-prop pre-pass does not number a require that sits below the first css prop.
  • require('styled-components').default is now recognized as the styled binding (previously missed), and sc['default'].div now gets a displayName/componentId.
  • Visitor signatures: isStyled(t) now takes path.scope; displayNameAndId, minify, pure, and transpile updated accordingly.
  • Fixtures and compat matrix: many new fixture families (aliased imports, namespace default, computed variable keys, shadowing, reassignment, TS assertions) plus a compact import × access × export matrix in scripts/compat.js.

Verification: pnpm test 118 pass, pnpm build ok, detectors coverage 94%/92%, and pnpm bench --rounds 3 shows parity or slight improvement over main on all four inputs.

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

v7's styled factory is a proxy, so a tag allowlist dropped display names
for properties such as box and feNotAFilter. Factory bindings now use
open mode. Static computed helper keys stay plain, and a use written
before a reassignment keeps its id.

Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>
@cursor cursor Bot changed the title fix: don't treat non-tag exports on require bindings as styled tags fix: keep display names on unknown styled tags, and leave helpers alone Oct 22, 2026

@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 open-mode path decorates the factory's inherited Function.prototype methods, so sc.bind(null) / sc.call(...) / sc.apply(...) compile to sc.bind.withConfig(...) and throw on v7. A few names in OPEN_SKIP close it.

Reviewed changes

This run re-reviewed the delta since the prior pullfrog review at 96da99b: commit c357cb6, which replaces the tag allowlist with open-mode decoration for every factory binding and adds computed-key resolution and source-ordered reassignment handling.

  • Every factory binding now uses open mode (src/utils/detectors.js): require, default/named styled imports, aliased imports, .default / ['default'], the interop default helper, and namespace imports all decorate unknown members and skip OPEN_SKIP names. This restores display names/ids for v7 proxy tags (styled.box, styled.feNotAFilter) while keeping isStyledComponent, css, and the Object.prototype methods plain.
  • Computed keys resolve statically (resolveStaticKey): a string, a no-interpolation template, or a const chain of those resolves to a name and consults OPEN_SKIP; let/var/calls stay dynamic and decorated.
  • Reassignment is source-ordered (assignmentPrecedes + a referenceNode threaded through styledBindingKind): a use written before a let/var reassignment keeps its id (including alias chains, which capture the value at the aliasing point), and a use written after it does not.
  • Deleted src/utils/domElements.js and isStyledTag; updated the changeset, AGENTS.md, the compat matrix, and fixtures accordingly.

Verification: pnpm test 120 pass, pnpm build ok, node scripts/compat.js passes on v5.3.11 / v6.6.0 / v7 prerelease (and fixes a base render failure for sc['default'](Base)), and enumerating every actual v5/v6/v7 export against OPEN_SKIP shows no real export is decorated.

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

Comment thread src/utils/detectors.js Outdated
Skip keys that live on Object.prototype or Function.prototype. None of
those names are DOM or SVG tags. A computed key stays plain unless it
folds to a known component, except where an import named styled already
received an id. A nested function or a loop that can run after a
reassignment stays plain, and a straight-line use before that assignment
keeps its id.

Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>

@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

This run re-reviewed the delta since the prior pullfrog review at c357cb6: commit 5aa81ee, which closes the Function.prototype gap flagged last time and narrows unresolved-computed-key decoration.

  • Prototype keys skipped generically (src/utils/detectors.js): isBuiltinPrototypeKey (name in Object.prototype || name in Function.prototype) replaces the hand-listed Object.prototype names in OPEN_SKIP and adds the Function.prototype names, so sc.bind/sc.call/sc.apply, the legacy __* accessors, and name/length/caller/arguments compile unchanged.
  • Unresolved computed keys match 2.3.0 (legacyDefaultLocal / legacyDecoratesUnresolved): a key that does not fold is decorated only for the names 2.3.0 decorated. sc[dynamic] on a require named something other than styled stays plain; styled[dynamic], a default import's own local, and ns[dynamic] stay decorated. styled[css] is still suppressed through isHelper.
  • Template folding extended (resolveStaticKey): a template whose parts all fold to strings resolves to a key, so sc[`${'css'}`] is treated as css and left plain.
  • Reassignment safety widened (reassignmentUnsafe + inNestedFunction / loopReassigns): a use inside a nested function, or inside a loop that also reassigns the binding, stays plain; a straight-line use in the binding's own scope written before the assignment keeps its id.
  • Fixtures and matrix: prototype-member, unresolved-key, nested-function, and loop-reassignment cases, plus the matching scripts/compat.js matrix entries and the changeset/AGENTS.md updates.

I independently compiled an import × access × export matrix with the built head plugin against published 2.3.0: the head is a strict superset for tags and a strict subset for helpers and prototype names. No tag is left plain that 2.3.0 decorated, and no helper is decorated that 2.3.0 left plain. sc.bind, sc.call, sc.apply, sc.__proto__, and sc.name now compile unchanged, which addresses the single prior thread (resolved). pnpm test passes (127) and pnpm build succeeds.

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

@quantizor
quantizor merged commit d716e12 into main Oct 22, 2026
6 checks passed
@quantizor
quantizor deleted the cursor/fix-require-nontag-exports-f8f9 branch October 22, 2026 22:24
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.

1 participant