-
Notifications
You must be signed in to change notification settings - Fork 13.5k
fix(react): support React refs (object and callback) #23152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(react): support React refs (object and callback) #23152
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great job! PR will be approved once the changes noted below are made.
Here is a dev build if you would like to test this in your app:
npm install @ionic/react@5.7.0-dev.202104151952.ec15e01 @ionic/react-router@5.7.0-dev.202104151952.ec15e01
@@ -107,7 +111,11 @@ export const createControllerComponent = < | |||
// It's also possible for the component to have become unmounted. | |||
if (this.props.isOpen === true && this.isUnmounted === false) { | |||
if (this.props.forwardedRef) { | |||
(this.props.forwardedRef as any).current = this.overlay; | |||
if (typeof this.props.forwardedRef === 'function') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we extract this to a shared function in the utils
file? Something like:
const setRef = (ref, value = null) => {
if (typeof ref === 'function') {
ref(value);
} else {
ref.current = value
}
};
That way we can use this in both createControllerComponent.tsx
and createOverlayComponent.tsx
.
ref(value) | ||
} else if (ref != null) { | ||
// Cast as a MutableRef so we can assign current | ||
(ref as React.MutableRefObject<any>).current = value |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can probably use the helper function here as well (we may need to cast as MutableRef in the helper function)
...refs: (React.ForwardedRef<any> | React.Ref<any> | undefined)[] | ||
) => { | ||
return (value: any) => { | ||
refs.forEach(ref => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to return anything here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't believe react has any requirements or expectations for the return value of a callback ref, but I will double-check and confirm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed the docs referenced above show an example with no return value, and the typescript types show the ForwardedRef function format and RefCallback should return void.
Hey @liamdebeasi - I believe all feedback has been addressed in the updated commit, please let me know if there are any other required changes -- thanks for reviewing this! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested an updated dev build (5.7.0-dev.202104161346.589a1b8
) and it looks like the onIonChange
handler is being fired twice now. I am testing this with the CodeSandbox found in #23153.
If I remove the ref
or use a non-callback ref then the onIonChange
handler only fires once. My guess is maybe it has something to do with the mergeRefs
function.
Steps to reproduce:
- Open CodeSandbox on issue I linked to with the dev build installed.
- Type
a
. Observe that the console logsonChange a
. - Type
b
. Observe that the console logsonChange ab
twice.
const ionButtonRef: React.RefObject<any> = React.createRef(); | ||
const IonButton = createReactComponent<JSX.IonButton, HTMLIonButtonElement>('ion-button'); | ||
|
||
const { getByText } = render(<IonButton ref={ionButtonRef}>ButtonNameA</IonButton>); | ||
const ionButtonItem = getByText('ButtonNameA'); | ||
expect(ionButtonRef.current).toEqual(ionButtonItem); | ||
}); | ||
|
||
test('should pass ref on to web component instance (RefCallback)', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should add a test here to test that onIonChange
is called at most once at a time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @liamdebeasi - I'm unsure how to go about introducing this test. I looked throughout the project and couldn't find any examples where the web components were being rendered and their behaviors verified. If you have any pointers on how to get everything set up, I'd be more than happy to add this!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't actually tested this, but I was thinking something like the following:
let current;
const ionInputRef: React.RefCallback<any> = value => current = value;
const changeCallback = jest.fn();
const IonInput = createReactComponent<JSX.IonInput, HTMLIonInputElement>('ion-input');
const { getBy } = render(<IonInput id="my-input" onIonChange={changeCallback} ref={ionInputRef}></IonInput>);
const ionInputItem = getBy('#my-input');
ionInputItem.value = 'abc';
expect(changeCallback).toBeCalledTimes(1);
ionInputItem.value = 'abcxyz';
expect(changeCallback).toBeCalledTimes(2);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Directly assigning to .value
doesn't seem to trigger the onIonChange
-- From what I could tell, this setup doesn't actually render the ion-input
WebComponent.
I tried to use fireEvent.change(ionInput, { target: { value: '' } }
but that throws "The given element does not have a value setter".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 Weird. Let's hold off on this. I posted the suggestion before I saw #23152 (comment), so I'll do some more digging and see if we even need to add this.
I've pushed another code change to ensure the refs remain stable between renders (I don't believe this is the cause of the issue described above, but it was another issue I found while debugging). That being said, I'm not sure the double onIonChange is actually caused by these changes (it appears to happen with both dev builds as well). I tried to recreate an example without react-hook-form and was unable to, which makes me lean towards this being a bug in that library. -- For reference, I tried various combinations of object refs, react refs, inline functions, inline functions that set refs, inline functions that returned refs, etc, and was unable to produce the double event behavior seen with the react-hook-form ref. |
Hmm ok I will take another look. If the issue is not in Ionic React then we probably do not need to add the other test I mentioned. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so looks like if you type "a", the react-hook-form
package sets that as the input's default value. Then when you type "ab", it sets the value to the default value and then sets it to "ab" for some reason. The actual Ionic event still shows "ab" as the value, but I agree with your original suggestion that this looks like a bug in the react-hook-form
.
Code looks good to go. Thanks for putting up this PR!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One more question -- do we need to make these React.ForwardedRef
as well?
IonTabButton:
ref?: React.RefObject<HTMLIonTabButtonElement>; |
IonBackButton:
ref?: React.RefObject<HTMLIonBackButtonElement>; |
Hey @liamdebeasi -- I updated the two components to use I could probably update them to use the Edit: Found one more in |
React refs can be an object, or a callback - callbacks are commonly used by 3rd party libraries for combining multiple refs. https://reactjs.org/docs/refs-and-the-dom.html#callback-refs
That all makes sense. I agree we should not introduce a breaking change with this. Thanks! |
Merged. Thank you very much for the PR! |
Hey @liamdebeasi - Thanks for reviewing and helping to get this merged, looking forward to this being included in an upcoming release :D (now I just need to sort out that react-hook-form bug... haha) |
We just shipped v5.6.5 a few hours ago that includes this fix 😄 |
@TuckerWhitehouse Hey there, this seems to have caused a regression in #23287. When an Ionic component re-renders, the ref value becomes |
@liamdebeasi I believe the code referenced in that ticket is using |
Ah good catch! In this case, should we be calling that |
Oh hmm... I guess you would use |
Yup, |
Thanks for the help! |
React refs can be an object, or a callback - callbacks are
commonly used by 3rd party libraries for combining multiple refs.
https://reactjs.org/docs/refs-and-the-dom.html#callback-refs
Pull request checklist
Please check if your PR fulfills the following requirements:
npm run build
) was run locally and any changes were pushednpm run lint
) has passed locally and any fixes were made for failuresPull request type
Please check the type of change your PR introduces:
What is the current behavior?
Issue Number: #23153
Providing a callback ref breaks internal assumptions that forwardedRefs will always be in the object format.
What is the new behavior?
Does this introduce a breaking change?
Other information