bugfix(className): keep the sc-* hook class when a consumer styles a component (CUI-42) - #1185
bugfix(className): keep the sc-* hook class when a consumer styles a component (CUI-42)#1185JeanMarcMilletScality wants to merge 1 commit into
Conversation
Hello jeanmarcmilletscality,My role is to assist you with the merge of this Available options
Available commands
Status report is not available. |
Waiting for approvalThe following approvals are needed before I can proceed with the merge:
Peer approvals must include at least 1 approval from the following list: |
Waiting for approvalThe following approvals are needed before I can proceed with the merge:
Peer approvals must include at least 1 approval from the following list: |
| @@ -0,0 +1,30 @@ | |||
| import '@testing-library/jest-dom'; | |||
There was a problem hiding this comment.
Modal.test.tsx already exists in this directory (testing stacking order). Adding Modal.component.test.tsx creates two test files for the same module — the exact duplication the project's CLAUDE.md convention warns against:
"Two names for one component (
Modal.test.tsxandModal.component.test.tsx) is the failure this prevents."
Add the new describe('className preservation', …) block inside the existing Modal.test.tsx instead.
f1b7038 to
5bb2c39
Compare
| @@ -33,7 +33,9 @@ type Props = { | |||
| */ | |||
| 'aria-label'?: string; | |||
| }; | |||
There was a problem hiding this comment.
className?: string is missing from Props. Every other component in this PR adds it to its Props type, but Dropdown was skipped. The // @ts-nocheck at the top masks the omission locally, but consumers in type-checked files writing <Dropdown className="x" /> will get TS2322.
| }; | |
| }; | |
| const DropdownStyled = styled.div.withConfig({ |
(Can't suggest the Props change in this hunk — add className?: string; to the Props type on line 20, alongside the other fields.)
Wrapping a component in `styled()` silently stripped its documented `sc-*`
class from the DOM, so every CSS override, selector and trace lookup keyed on
that class stopped matching. Nothing failed loudly — the component still
rendered and still looked right.
`className` is an ordinary prop with one slot, and two parties wrote it: the
library (`sc-modal`) and the consumer, since `styled(Component)` restyles by
generating a class and passing it *as* `className`. The hook class was written
into that slot ahead of `{...rest}`, and a JSX spread is last-wins, so the
consumer always won. Reordering the spread only swaps the victim — it keeps
the hook class and drops the consumer's, leaving `styled()` a silent no-op.
Declare the class through `withConfig({ componentId })` instead. styled-
components then emits it after props are resolved, in the same pass that
appends the generated class, so it never competes for the prop slot and cannot
be clobbered; the consumer's `className` reaches the DOM untouched through the
existing spread; and `${Container}` component selectors resolve to the same
class. The emitted class is byte-identical — `componentId` alone adds no hash
and no prefix, and this package builds with plain `tsc`, so the babel plugin's
displayName prefix does not apply. No consumer needs to change.
22 componentId declarations cover every hook class the library exposed as a
JSX prop. ErrorPageContainer is shared by four pages that each need a distinct
class, so each page gets a thin wrapper carrying only its componentId.
Also adds `className?: string` to the 19 Props types that never declared it.
That is a separate, compile-time defect: those components already forwarded
`className` to a DOM element, but tsc rejected `<Comp className="x" />`
(TS2322) and `<Styled className="x" />` (TS2769). The clobber itself was
runtime-only — `styled(Comp)` with no explicit `className` compiled fine.
Dropdown's omission was invisible locally because the file opens with
`// @ts-nocheck`, so tsc never reads it; a type-level probe over all 21
components confirms every one now declares `className`.
The regression test goes in the existing `Modal.test.tsx` as a sibling
describe, rather than in a second test file for the same component.
CUI-42 proposed merging the classes by hand and moving `{...rest}` ahead of the
props the library owns. The merge is superseded; the reordering is left out
deliberately, since it would change prop precedence for `role` and the `aria-*`
attributes across 21 components.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5bb2c39 to
b3afcc8
Compare
TL;DR
Wrapping a core-ui component in
styled()silently stripped its documentedsc-*class from the DOM, so every CSS override, selector and trace lookup keyed on that class stopped matching. The class is now declared as the styled-componentscomponentIdinstead of as a JSX prop, which puts it beyond the reach of a props spread. The emitted class is byte-identical, so nothing downstream changes.Context
styled(Component)restyles by generating a class and passing it asclassName— so a consumer using the documented way to restyle was overwriting the hook class. Nothing failed loudly: the component still rendered and still looked right, which is why it went unnoticed. Surfaced downstream while tracing an e2e failure during a dependency uplift; see CUI-42.Approach
classNameis an ordinary prop with exactly one slot, and two parties wanted to write it: the library (sc-modal) and the consumer (viastyled()or directly). We wrote ours where it loses — a JSX spread is last-wins.Before — the hook class in the contested slot:
After — the hook class declared on the component, never passed as a prop:
Only the consumer writes the
classNameprop now, so there is nothing to clobber.sc-modalnever enters the prop slot at all: styled-components emits it after props are resolved, in the same pass that appends the generated class and the folded ids.Why
componentIdrather than merging the two by handReordering the spread is not an option — it keeps
sc-modalbut drops the consumer's generated class, sostyled()silently does nothing. One slot cannot hold two authors, so something has to concatenate. Three candidates:.attrs({ className })className, with a long history of ordering/dropping bugs across majorswithConfig({ componentId })${Component}selectors already resolve to; cannot be clobberedThe library's documented contract is that a wrapped component "attach the passed
classNameprop to a DOM element" — the third option satisfies it by never competing for the prop in the first place.Measured
Real DOM output,
styled(Button)inside aButtonGroup(the group styles.sc-buttonto strip each child's framing, so a lost class silently detaches its CSS):sc-buttonpresentborder-right: 0.0625remAnd the class is unchanged for everyone else, which is what makes this a non-breaking change:
class<Modal>sc-modal cRtjOL<Modal className="mine">sc-modal cRtjOL minestyled(Modal)sc-modal cRtjOL sc-gWWYmq bDeILHcomponentIdalone emits exactly the literal string — no hash, no prefix. The babel plugin'sdisplayNameoption would prefix it (ModalContainer-sc-modal), but this package builds with plaintscand nobabel-plugin-styled-components, so what is declared is what ships.Usage
No API change. What the fix restores, as the regression test in
Modal.test.tsxexercises it:Before, that dialog root carried only the generated classes. Now it carries
sc-modalas well, so consumer CSS and selectors keyed on it keep matching through astyled()wrapper.Review focus
src/lib/components/*/…› the 22withConfig({ componentId })declarations — the consumer'sclassNamenow reaches the DOM only through the component's{...rest}spread, since nothing merges it any more. All 23 call sites were audited to confirm the spread lands on the DOM node; the bit worth confirming is that none was missed, because the failure mode is silent (class quietly absent, no error).src/lib/components/error-pages/ErrorPageStyle.ts› the four wrappers — one sharedErrorPageContainerhas to yield four distinct classes, so each page gets a thinstyled(ErrorPageContainer).withConfig({ componentId }). This adds the base container's generated class alongside the page's own; verified each page emits its own class and not its siblings'.src/lib/utils.ts›mergeClassNamesremoved — added earlier in this branch, unused once the class stops being a prop.How to test
No screenshot: the change is a DOM class, not a visual, and the emitted class is deliberately identical. The meaningful check is that a
styled()wrapper no longer detaches descendant CSS.npm run storybook→ Components / Button / Button Group.styled(Button)(any rule, e.g.letter-spacing: 1px) and reload.sc-buttonalongside the generated classes, and still renders de-framed — no border, transparent background, hairline separator from its neighbour.development/1.0the same wrap dropssc-button, and the button visibly regains its own border and background inside the group.npx jest— 44 suites / 436 tests, andnpx tsc --noEmitclean.Follow-up
ButtonGroup,NavbarandSidebartarget children by writing.sc-buttonin CSS; styled-components' intended mechanism for that is${ButtonStyled} { … }, which resolves to the same class and needs no naming convention.ButtonStyledis already exported and the repo already does this inCardandTablestyle. Once internal styling stops depending on the classes, what remains is a deliberate public CSS API rather than a convention maintained by hand — which also settles whether test hooks should move todata-*. Own ticket, not this PR.Dropdown.component.tsxopens with// @ts-nocheck. Removing it surfaces 9 pre-existing errors, all the same shape: transient style props ($variant,$size,$isOpen,$isSelected,$text) read in CSS interpolations onstyleddefinitions that were never given a generic type parameter. Worth typing properly — a blanket@ts-nocheckhides real API defects, which is exactly what happened toclassNamehere. Separate scope from this fix.sc-*classes. Out of scope here, but worth making the rule: assertions belong on user-facing behaviour, and test hooks ondata-*attributes.role/aria-*are exposed the same way. CUI-42 notes that{...rest}still lands afterrole,aria-modal,aria-labelledbyandaria-describedby, so a consumer can override core-ui's accessibility wiring. Deliberately unchanged: this PR alters no prop precedence, and fixing it would change override behaviour across 21 components — a separate decision from the class bug.References
classNamesilently clobbers the internalsc-*hook class". Bug / Severity Major / Impact Internal. Carries the downstream report, the original DOM evidence, and the merge-based fix this supersedes.What changed
22
componentIddeclarations across 19 styled containers, covering everysc-*hook class the library exposed through a JSX prop:sc-breadcrumb,sc-button,sc-button-group,sc-circularprogressbar,sc-dropdown,sc-error-page401/404/500/auth,sc-lateralnavbarlayout,sc-layout,sc-loader,sc-modal,sc-navbar,sc-notification,sc-notifications,sc-progressbar,sc-searchinput,sc-sidebar,sc-sidebar-item,sc-steppers,sc-textarea. Each site drops its hardcodedclassNameand stops destructuringclassNameout of props, so the consumer's class flows through the existing spread untouched.className?: stringis added to 19Propstypes that never declared it. That is a separate, compile-time defect: those components already forwardedclassNameto a DOM element, buttscrejected<Comp className="x" />(TS2322) and<Styled className="x" />(TS2769). The clobber itself was runtime-only —styled(Comp)with no explicitclassNamecompiled fine.Dropdownwas the one component where this was easy to miss: the file opens with// @ts-nocheck, sotscnever reads it and the omission raised nothing locally while still breaking consumers in type-checked files. A type-level probe over all 21 components confirms every one now declares it.CUI-42 proposed merging the classes and moving
{...rest}ahead of the props core-ui owns. The merge is superseded bycomponentId; the reordering is deliberately left out, since it changes prop precedence forroleand thearia-*attributes too.The regression test is a sibling
describein the existingModal.test.tsx, not a second test file for the same component.Deliberately not in this PR: converting internal descendant styling to component selectors, any
data-*test hooks, and therole/aria-*precedence question.🤖 Generated with Claude Code