refactor: @hcc/ui 패키지 재설계#565
Conversation
RecipeVariants를 사용해 ButtonVariants 타입 추출 방식을 개선하고, ButtonProps 인터페이스를 명시적으로 분리
primitive 기반의 TextField 컴포넌트를 추가하고 패키지 엔트리에 등록. Clear 버튼 클릭 시 input으로 포커스를 복원하는 동작 포함.
There was a problem hiding this comment.
Code Review
This pull request refactors the UI package to migrate from Radix UI to @base-ui/react and @vanilla-extract/css, introducing updated Button and Popover components, a new TextField component, and a custom slot utility. Key feedback points out several React Rules of Hooks violations where early returns occur before hook calls in Slot, TextFieldDescription, TextFieldLabel, and TextFieldClear. Additionally, the Card component ignores its header and title props, TextFieldInput causes a React warning by passing both value and defaultValue, the PopoverArrow has an unclosed SVG path, and the Button component is missing the data-loading attribute.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export interface CardProps { | ||
| // slots | ||
| header?: ReactNode; | ||
| title?: ReactNode; | ||
|
|
||
| children?: ReactNode; | ||
| } | ||
|
|
||
| export const Card = ({ children }: CardProps) => { | ||
| return ( | ||
| <CardRoot> | ||
| <CardHeader> | ||
| <CardTitle>Card Title</CardTitle> | ||
| </CardHeader> | ||
| <CardBody>{children}</CardBody> | ||
| <CardFooter /> | ||
| </CardRoot> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
CardProps 인터페이스에 header와 title 슬롯이 정의되어 있지만, Card 컴포넌트 구현부에서 이 프롭들을 구조 분해 할당하지 않고 무시하고 있으며, "Card Title"이라는 텍스트가 하드코딩되어 있습니다. 컴포넌트가 전달받은 header와 title 프롭을 올바르게 렌더링하도록 수정해야 합니다.
export interface CardProps {
// slots
header?: ReactNode;
title?: ReactNode;
children?: ReactNode;
}
export const Card = ({ header, title, children }: CardProps) => {
return (
<CardRoot>
{(header || title) && (
<CardHeader>
{header}
{title && <CardTitle>{title}</CardTitle>}
</CardHeader>
)}
<CardBody>{children}</CardBody>
<CardFooter />
</CardRoot>
);
};
| export const Slot = ({ render, ...props }: SlotProps) => { | ||
| if (!render) return null; | ||
|
|
||
| return useRender({ render, props }); | ||
| }; |
There was a problem hiding this comment.
React Hook인 useRender가 조건부로 호출되고 있습니다. if (!render) return null;과 같이 Hook 호출 전에 조기 반환(early return)을 수행하면 React의 Hook 규칙(Rules of Hooks)을 위반하게 됩니다. Hook은 항상 최상위 레벨에서 무조건적으로 호출되어야 합니다. useRender를 먼저 호출한 뒤 조건부 반환을 수행하도록 수정해야 합니다.
| export const Slot = ({ render, ...props }: SlotProps) => { | |
| if (!render) return null; | |
| return useRender({ render, props }); | |
| }; | |
| export const Slot = ({ render, ...props }: SlotProps) => { | |
| const element = useRender({ render, props }); | |
| if (!render) return null; | |
| return element; | |
| }; |
| export const TextFieldDescription = forwardRef<HTMLSpanElement, TextFieldDescription.Props>( | ||
| ({ render, id: idProp, className, children, ...props }, ref) => { | ||
| if (!children) return null; | ||
|
|
||
| const { descriptionId } = useTextFieldContext(); | ||
| const id = idProp ?? descriptionId; | ||
|
|
||
| return useRender({ | ||
| ref, | ||
| render, | ||
| defaultTagName: 'span', | ||
| props: { | ||
| id, | ||
| className: clsx(styles.description, className), | ||
| children, | ||
| ...props, | ||
| }, | ||
| }); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
React Hook인 useRender가 조건부로 호출되고 있습니다. if (!children) return null;과 같이 Hook 호출 전에 조기 반환을 수행하면 React의 Hook 규칙(Rules of Hooks)을 위반하게 됩니다. useRender를 먼저 호출한 뒤 조건부 반환을 수행하도록 수정해야 합니다.
export const TextFieldDescription = forwardRef<HTMLSpanElement, TextFieldDescription.Props>(
({ render, id: idProp, className, children, ...props }, ref) => {
const { descriptionId } = useTextFieldContext();
const id = idProp ?? descriptionId;
const element = useRender({
ref,
render,
defaultTagName: 'span',
props: {
id,
className: clsx(styles.description, className),
children,
...props,
},
});
if (!children) return null;
return element;
},
);
| import { useTextFieldContext } from './context'; | ||
|
|
||
| export const TextFieldLabel = forwardRef<HTMLLabelElement, TextFieldLabel.Props>( | ||
| ({ render, htmlFor: htmlForProp, className, children, ...props }, ref) => { | ||
| if (!children) return null; | ||
|
|
||
| const { inputId, labelPosition } = useTextFieldContext(); | ||
|
|
||
| return useRender({ | ||
| ref, | ||
| render, | ||
| defaultTagName: 'label', | ||
| props: { | ||
| htmlFor: htmlForProp ?? inputId, | ||
| className: clsx(styles.label({ labelPosition }), className), | ||
| children, | ||
| ...props, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
React Hook인 useTextFieldContext와 useRender가 조건부로 호출되고 있습니다. if (!children) return null;과 같이 Hook 호출 전에 조기 반환을 수행하면 React의 Hook 규칙(Rules of Hooks)을 위반하게 됩니다. 모든 Hook 호출을 최상위로 올리고 조건부 반환은 그 이후에 수행하도록 수정해야 합니다.
export const TextFieldLabel = forwardRef<HTMLLabelElement, TextFieldLabel.Props>(
({ render, htmlFor: htmlForProp, className, children, ...props }, ref) => {
const { inputId, labelPosition } = useTextFieldContext();
const element = useRender({
ref,
render,
defaultTagName: 'label',
props: {
htmlFor: htmlForProp ?? inputId,
className: clsx(styles.label({ labelPosition }), className),
children,
...props,
},
});
if (!children) return null;
return element;
},
);
| export const TextFieldInput = forwardRef<HTMLInputElement, TextFieldInput.Props>( | ||
| ({ render, id: idProp, className, ...props }, ref) => { | ||
| const { inputId, inputRef, value, defaultValue, onValueChange } = useTextFieldContext(); | ||
|
|
||
| const mergedRefs = useMergedRefs(ref, inputRef); | ||
| const id = idProp ?? inputId; | ||
|
|
||
| return ( | ||
| <BaseInput | ||
| ref={mergedRefs} | ||
| render={render} | ||
| id={id} | ||
| value={value} | ||
| defaultValue={defaultValue} | ||
| // onValueChange={onValueChange} | ||
| onChange={(event) => onValueChange(event.currentTarget.value, event)} | ||
| className={clsx(styles.input, className)} | ||
| {...props} | ||
| /> | ||
| ); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
TextFieldRoot에서 useControlled를 통해 value 상태가 항상 제어되고 있으므로, 하위 BaseInput에 value와 defaultValue를 동시에 전달하면 React에서 제어/비제어 컴포넌트 경고(Warning: Input elements must be either controlled or uncontrolled...)가 발생합니다. defaultValue 전달을 제거하고 value만 사용하도록 수정해야 합니다.
export const TextFieldInput = forwardRef<HTMLInputElement, TextFieldInput.Props>(
({ render, id: idProp, className, ...props }, ref) => {
const { inputId, inputRef, value, onValueChange } = useTextFieldContext();
const mergedRefs = useMergedRefs(ref, inputRef);
const id = idProp ?? inputId;
return (
<BaseInput
ref={mergedRefs}
render={render}
id={id}
value={value}
onChange={(event) => onValueChange(event.currentTarget.value, event)}
className={clsx(styles.input, className)}
{...props}
/>
);
},
);
| return useRender({ | ||
| ref, | ||
| render, | ||
| defaultTagName: 'button', | ||
| enabled: !!value, | ||
| props: { | ||
| className: clsx(styles.clear, className), | ||
| onClick: handleClick, | ||
| children, | ||
| ...props, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
value가 비어있을 때 빈 버튼이 렌더링되는 것을 방지하기 위해 조건부 렌더링을 적용해야 합니다. 이때 React Hook 규칙(Rules of Hooks)을 준수하기 위해 useRender를 무조건적으로 호출한 뒤, value가 없을 때 null을 반환하도록 구현해야 합니다.
const element = useRender({
ref,
render,
defaultTagName: 'button',
props: {
className: clsx(styles.clear, className),
onClick: handleClick,
children,
...props,
},
});
if (!value) return null;
return element;
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| width="12" | ||
| height="12" | ||
| viewBox="0 0 12 12" | ||
| fill="currentColor" | ||
| > | ||
| <path d="M0 0 L6 6 L12 0" /> | ||
| </svg> |
There was a problem hiding this comment.
SVG path 요소의 d 속성이 닫히지 않은 상태(M0 0 L6 6 L12 0)로 정의되어 있습니다. 브라우저 환경에 따라 채우기(fill)나 테두리(stroke) 렌더링이 일관되지 않게 보일 수 있으므로, 경로 끝에 Z (closepath) 명령어를 추가하여 경로를 명시적으로 닫아주는 것이 안전합니다.
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="12" | |
| height="12" | |
| viewBox="0 0 12 12" | |
| fill="currentColor" | |
| > | |
| <path d="M0 0 L6 6 L12 0" /> | |
| </svg> | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="12" | |
| height="12" | |
| viewBox="0 0 12 12" | |
| fill="currentColor" | |
| > | |
| <path d="M0 0 L6 6 L12 0 Z" /> | |
| </svg> |
| return ( | ||
| <PrimitiveButton.Root | ||
| render={render} | ||
| size={size} | ||
| color={color} | ||
| variant={variant} | ||
| disabled={disabled} | ||
| {...props} | ||
| > | ||
| <slots.left render={left} /> | ||
|
|
||
| {children} | ||
|
|
||
| <slots.right render={right} /> | ||
| </PrimitiveButton.Root> | ||
| ); |
There was a problem hiding this comment.
기존 구현에서는 data-loading 속성을 전달하여 CSS에서 로딩 상태 스타일링을 처리할 수 있었으나, 리팩토링된 코드에서는 loading 프롭이 구조 분해 할당으로 제외된 후 PrimitiveButton.Root로 전달되지 않고 있습니다. 로딩 상태 스타일링을 원활하게 지원하기 위해 data-loading 속성을 명시적으로 전달하는 것이 좋습니다.
return (
<PrimitiveButton.Root
render={render}
size={size}
color={color}
variant={variant}
disabled={disabled}
data-loading={loading ? 'true' : undefined}
{...props}
>
<slots.left render={left} />
{children}
<slots.right render={right} />
</PrimitiveButton.Root>
);
✅ 작업 내용
@hcc/uiUI 패키지 아키텍처 재설계Button,TextField) 사용ButtonPrimitives,TextFieldPrimitives.Root/Container/Input/Label/...) 조합left,right등 임의 위치에 컴포넌트 주입 가능vanilla-extract로 통일popover패키지 단으로 이관 (spectator 내부 구현 제거)@hcc/ui/primitives)사용 예시