회사에서 rmc-picker라는 라이브러리를 사용하여 wheel picker를 구현하고 있었는데, 어느 순간부터 제대로 동작하지 않는 상황이 발생했다 😨 원인을 파악하고 나니 패키지 커스텀 밖에 답이 없어서 patch-package
를 시도하게 되었고, 다행히 성공했다!
먼저 원인을 어떻게 찾게 되었는지를 회상해 보면서 이후의 patch 과정을 담아보려 한다.
wheel picker가 스크롤될 때 실행되어야 하는 callback이 갑자기 실행되지 않았다. 그리고 wheel picker가 렌더링될 때 아래의 에러 메시지들이 뜨기 시작했다.
selectedValue
prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase selectedvalue
instead. If you accidentally passed it from a parent component, remove it from the DOM element.onScrollChange
. It will be ignored.매우 당황스러웠다. selectedValue
와 onScrollChange
는 라이브러리 docs에 명시된 prop들인데, 왜 ignore 된다는 거지...? 깃허브 issue를 확인해 보았지만 이에 대한 변경 사항은 없는 듯 했다.
2~30분을 이것저것 만져 보다가, 일단 에러 메시지부터 해결해 봐야겠다 싶어서 selectedValue
가 DOM element에 넘겨졌는지 보려고 개발자 도구를 켜봤다.
확인해 보니, UI 커스텀을 위해 만들었던 side div에 selectedValue
가 넘겨져 있었다. 이게 왜 여기로 넘어가지?
<MultiPicker
selectedValue={wheelPickerHook.selectedValues}
onScrollChange={wheelPickerHook.handleScroll}
>
<div className="rmc-picker-indicator indicator side" />
위 코드의 div
가 저 side div에 해당하는데, 난 selectedValue
를 전혀 넘겨주지 않고 있다. 이건 라이브러리를 뜯어 봐야겠구나 싶어서 소스코드를 확인해 보았다.
확인해 보니, children의 각 child를 cloneElement
하면서 3가지 prop을 넘겨주고 있었다.
나처럼 커스텀하는 경우는 고려되지 않은 설계였다. 아래의 라이브러리 example처럼 MultiPicker
하위에 Picker
만 오면 괜찮은데,
나는 아래처럼 커스텀을 했기 때문이다... 🙃
<MultiPicker
selectedValue={wheelPickerHook.selectedValues}
onScrollChange={wheelPickerHook.handleScroll}
>
<div className="rmc-picker-indicator indicator side" />
{wheelPickerHook.itemLists.map((itemList, listIndex) => {
const hasLeftGap = !!listIndex;
return (
<React.Fragment key={listIndex}>
{hasLeftGap && (
<div className="rmc-picker-indicator indicator gap" />
)}
<Picker indicatorClassName="indicator">
{itemList.map(item => (
<Picker.Item
key={item.value}
className="item"
value={item.value}
>
<span>{item.text}</span>
</Picker.Item>
))}
</Picker>
</React.Fragment>
);
})}
<div className="rmc-picker-indicator indicator side" />
</MultiPicker>
라이브러리 설계상 Picker
만 사용해서는 UI 커스텀이 불가능한 상황이었다. 아무튼, 소스코드에 따르면 내 코드에선 Picker
가 아닌 side div와 Fragment가 cloneElement
되고 있었기 때문에 warning이 뜬 거였다. 그리고 Fragment 내에 있는 Picker
는 clone 되지 못해서 props를 받지 못했으므로 스크롤될 때의 callback인 onScrollChange
가 실행되지 않았던 것이었다. 갈 길은 멀어 보이는데 원인을 찾아서 기뻤다... 🎉
따라서, 난 다음의 action을 취해야 했다.
Picker
가 cloneElement
되도록 해야 한다.patch-package
는 node_modules의 변경 사항을 git으로 관리하여 어떤 실행 환경에서도 node_modules에 변경 사항이 적용될 수 있게 해주는 라이브러리이다.
yarn add -D patch-package
postinstall
스크립트를 아래와 같이 추가한다.node_modules를 변경한다.
yarn patch-package 패키지명
을 하면, patch 파일이 생성된다.
이 patch 파일을 통해, yarn
을 했을 때 변경 사항이 반영된 패키지를 설치할 수 있게 된다.
그런데 package가 업데이트 되면 patch한 것과 호환 문제가 발생할 수 있다는 사실을 주의해야 한다.
그럼 rmc-picker를 어떻게 patch 했는가?
우선 크게 4가지 질문에 대해 Chat GPT로부터 힌트를 얻어 로직의 틀을 작성했다.
map
을 실행하는 방법const hasLeftGap = !!listIndex;
return (
<React.Fragment key={listIndex}>
{hasLeftGap && (
<div className="rmc-picker-indicator indicator gap" />
)}
위 코드에서, boolean인 hasLeftGap
또한 child로 여겨진다는 것을 알게 되었다. 이러한 것들을 제외시키기 위해, React.isValidElement()
를 활용해 React element를 식별할 수 있었다.
https://react.dev/reference/react/isValidElement
오직 Picker
만 cloneElement
를 해야 했고, 커스텀을 위해 넣은 단순 div들은 제외시켜야 했기에 단순 html element를 식별하고자 했다.
이때 알게된 사실은 React에서 렌더링하는 html element는 HTMLElement
의 직접적인 instance가 아니라는 사실이다. 모든 React element는 createElement
를 통해 생성되는 JS 객체이기 때문에, instanceof HTMLElement
를 이용할 수 없었다. 따라서 단순 div들을 걸러내기 위해서는 React element의 type
property를 이용해야 했다.
나는 div로 커스텀을 했으므로 element의 type
=== div
인 경우를 상정했는데, 일반적인 html element를 타겟팅 한다면 type
의 typeof === string
인 경우를 상정하면 될 것 같다.
Fragment가 아닌 Fragment 내의 Picker
를 clone해야 했기에 일단 Fragment를 먼저 식별해야 했고, 이때 React element의 type
=== React.Fragment
인지 확인함으로써 가능했다.
map
을 실행하는 방법React.Children.map()
을 사용하면 children에 대해 map
을 실행할 수 있다. 난 child 중에서도 function component의 경우에만 cloneElement
를 하는 로직을 작성했다.
틀을 작성한 이후엔 JS와의 싸움이었다. 끝없는 console.log()를 통해 디버깅할 수 있었다 🥹
에러 메시지 없이 잘 동작하게 되었다!
var MultiPicker = function MultiPicker(props) {
var prefixCls = props.prefixCls,
className = props.className,
rootNativeProps = props.rootNativeProps,
children = props.children,
style = props.style;
var selectedValue = props.getValue();
var colElements = React.Children.map(children, function (col, i) {
return React.cloneElement(col, {
selectedValue: selectedValue[i],
onValueChange: function onValueChange() {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return props.onValueChange.apply(props, [i].concat(args));
},
onScrollChange: props.onScrollChange && function () {
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
args[_key2] = arguments[_key2];
}
return props.onScrollChange.apply(props, [i].concat(args));
}
});
});
return React.createElement("div", _extends({}, rootNativeProps, {
style: style,
className: (0, _classnames["default"])(className, prefixCls)
}), colElements);
};
var MultiPicker = function MultiPicker(props) {
var prefixCls = props.prefixCls,
className = props.className,
rootNativeProps = props.rootNativeProps,
children = props.children,
style = props.style;
var selectedValue = props.getValue();
// 현재 clone해야 하는 picker의 index
let pickerIndex = 0;
// clone할 때 필요한 props를 구하는 함수를 추가했다.
const getProps = () => {
const index = pickerIndex;
const result = {
selectedValue: selectedValue[index],
onValueChange: function onValueChange() {
for (
var _len = arguments.length, args = new Array(_len), _key = 0;
_key < _len;
_key++
) {
args[_key] = arguments[_key];
}
return props.onValueChange.apply(props, [index].concat(args));
},
onScrollChange:
props.onScrollChange &&
function () {
for (
var _len2 = arguments.length, args = new Array(_len2), _key2 = 0;
_key2 < _len2;
_key2++
) {
args[_key2] = arguments[_key2];
}
return props.onScrollChange.apply(props, [index].concat(args));
},
};
pickerIndex++;
return result;
};
// MultiPicker의 children에 대해
var colElements = React.Children.map(children, function (col) {
// 1-1. React element인 경우
if (React.isValidElement(col)) {
switch (col.type) {
// 2-1. Fragment인 경우 clone된 Picker들을 가진 새로운 Fragment를 반환한다.
case React.Fragment: {
// Fragment의 children에 대해
const children = React.Children.map(col.props.children, child => {
// 3-1. React element인 경우
if (React.isValidElement(child)) {
// 4-1. div인 경우 연산을 가하지 않는다.
if (child.type === 'div') {
return child;
// 4-2. function component(Picker)인 경우 clone한다. (Fragment인 경우는 생각하지 않음)
} else {
return React.cloneElement(child, getProps());
}
// 3-2. React element가 아닌 경우 연산을 가하지 않는다.
} else {
return child;
}
});
return React.createElement(React.Fragment, null, ...children);
}
// 2-2. div인 경우 연산을 가하지 않는다.
case 'div':
return col;
// 2-3. function component(Picker)인 경우 clone한다. (현재는 존재하지 않는 케이스)
default:
return React.cloneElement(col, getProps());
}
// 1-2. React element가 아닌 경우 연산을 가하지 않는다. (현재는 존재하지 않는 케이스)
} else {
return col;
}
});
return React.createElement(
'div',
_extends({}, rootNativeProps, {
style: style,
className: (0, _classnames['default'])(className, prefixCls),
}),
colElements,
);
};
cloneElement
에만 정신이 팔려 있었는데, 그에 쓰이는 props 계산 로직도 변경해야 한다는 것을 나중에야 깨달았다. 한 부분에만 집중하지 말고 전체적으로 살펴보자.글로만 보면 간단하게 보일 수 있지만 9~10시간이 걸렸던 대장정이었다... 그래도 라이브러리 사용에 자신감을 붙여준 유익한 경험이었고 wheel picker를 생각했던 대로 구현할 수 있어서 다행이었다. 다음에 또 patch를 하게 된다면 오늘보다 삽질을 훨씬 더 줄일 수 있을 것 같다. 👏
그리고 내가 짠 patch 코드로는 커버할 수 없는 edge case가 많아서 소스코드에 PR은 못 하겠지만, 내가 커스텀한 wheel picker를 패키지화 해보는 것도 괜찮지 않을까 싶어서 시간 될 때 해보고 싶다!