Repository navigation
fix: keep display names on unknown styled tags, and leave helpers alone - #430
Conversation
…yled tags Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>
Co-authored-by: Evan Jacobs <quantizor@users.noreply.github.com>
🦋 Changeset detectedLatest commit: 5aa81ee 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 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 newNON_TAG_EXPORTSset plus a guard on the firstisStyledmember arm, sosc.isStyledComponent(...),sc.createTheme(...),sc.extractCSS(),sc.toStyleSheet(), andsc.styled(...)on arequirebinding are no longer decorated as tags, whilesc.div/sc.default.divstill receive displayName and componentId. Helpers stay excluded throughisHelper. - Fixtures:
require-non-tag-exportsandrequire-non-tag-exports-styled-namepin 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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
|
The inline anchor from the review didn't attach, so the evidence is here. The guard added at
Computed access also sidesteps the guard, because 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'`).
|
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>
There was a problem hiding this comment.
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.Xandns.default.Xarms and taught it to resolve computed string keys throughmemberPropertyName, closing thesc.default.isStyledComponent,ns.default.isStyledComponent, andsc['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.jswith namespace-default and computed-access smoke cases, and broadened the changeset to describe the wider scope.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
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>
There was a problem hiding this comment.
✅ 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, anduseThemenow sit inNON_TAG_EXPORTS, sosc.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 whilesc.default['div']still gets adisplayNameandcomponentId. - Fixture coverage (
test/fixtures/require-non-tag-exports/,.../namespace-default-non-tag-exports/): added computed and.defaulthelper calls next to the still-decorated tags, pinning both directions of the behavior. - Compat smoke cases (
scripts/compat.js): addedns['css'],ns.default.css,sc['css'],sc.default.css, andsc['keyframes']with matchingplainmarkers 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.
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>
There was a problem hiding this comment.
✅ 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 toopen(import local namedstyled, decorates unknown members),allow(require(...)and aliased imports, decorates only allowlisted tags),namespace(import * as ns), ornull(shadowed/reassigned/unrelated). This closes the previous deny-list gap wheresc.somethingNewandsc.hasOwnPropertywere still rewritten. - New intrinsic-tag allowlist (
src/utils/domElements.js):STYLED_TAGSis consulted only forallow/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/requireBindingNameinassignStyledRequired.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').defaultis now recognized as the styled binding (previously missed), andsc['default'].divnow gets adisplayName/componentId.- Visitor signatures:
isStyled(t)now takespath.scope;displayNameAndId,minify,pure, andtranspileupdated 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.
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>
There was a problem hiding this comment.
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/namedstyledimports, aliased imports,.default/['default'], the interop default helper, and namespace imports all decorate unknown members and skipOPEN_SKIPnames. This restores display names/ids for v7 proxy tags (styled.box,styled.feNotAFilter) while keepingisStyledComponent,css, and theObject.prototypemethods plain. - Computed keys resolve statically (
resolveStaticKey): a string, a no-interpolation template, or aconstchain of those resolves to a name and consultsOPEN_SKIP;let/var/calls stay dynamic and decorated. - Reassignment is source-ordered (
assignmentPrecedes+ areferenceNodethreaded throughstyledBindingKind): a use written before alet/varreassignment 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.jsandisStyledTag; 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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
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>
There was a problem hiding this comment.
✅ 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-listedObject.prototypenames inOPEN_SKIPand adds theFunction.prototypenames, sosc.bind/sc.call/sc.apply, the legacy__*accessors, andname/length/caller/argumentscompile 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 thanstyledstays plain;styled[dynamic], a default import's own local, andns[dynamic]stay decorated.styled[css]is still suppressed throughisHelper. - Template folding extended (
resolveStaticKey): a template whose parts all fold to strings resolves to a key, sosc[`${'css'}`]is treated ascssand 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.jsmatrix 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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

CommonJS files that call helpers on the required styled-components module crashed at runtime after #429. This follows up that change.
Take this file:
The plugin rewrote the helper into
sc.isStyledComponent.withConfig(...)(A), which throwsTypeError: sc.isStyledComponent.withConfig is not a functionon 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.divon v7 (whererequire()returns the styled function with named exports attached) andsc.default.divon v5 and v6.I also added a changelog note for helpers imported before
styled, likeimport { 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.jsruns 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:
The plugin rewrote the helper into
sc.isStyledComponent.withConfig(...)(A), which throwsTypeError: sc.isStyledComponent.withConfig is not a functionon 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.divon v7 (whererequire()returns the styled function with named exports attached) andsc.default.divon v5 and v6.I also added a changelog note for helpers imported before
styled, likeimport { 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.jsruns 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, andstyled.feNotAFilterare real components. A fixed list of tag names was dropping those ids.These are the factory, under any local name:
styled.box,sc.box,sc.default.foo,ns.box, ands.feNotAFilterare 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.prototypeorFunction.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: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`], andsc[`${'css'}`]stay plain.const css = 'div'; sc[css]andsc.default[css]stay decorated. A key this plugin cannot resolve stays plain onrequire()and on an alias that is not namedstyled, solet dynamic = 'css'; sc[dynamic]returns the helper instead of throwing.styled[dynamic]on an import whose local name isstyledis still decorated, which matches the previous plugin.A
letorvarthat 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:A loop that reads
sc.divand then reassignsscdoes not throw on the next iteration. The straight-line use before that loop keeps its id.v5 native aliases such as
WebVieware covered by the same factory rule.import sc from 'styled-components/native'andrequire('styled-components/native')are the factory, sosc.WebViewgets 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, likeimport { 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:
node scripts/compat.jsruns the compact version of that matrix.