-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
[TextField] Converting TextField and Resizer to functional components using React Hooks #1997
Conversation
979fd9c
to
881e844
Compare
881e844
to
9863ab3
Compare
@@ -29,7 +29,7 @@ $stacking-order: ( | |||
flex-wrap: wrap; | |||
|
|||
> .Input { | |||
overflow: auto; | |||
overflow: hidden; |
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.
Not sure why but I think because the effects run after the function has run, the Textarea scrollbar was flickering when multiline.
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.
Yep the effects a ran after rendering the component. If you need some effect to happen before the dom is written you might be able to use useLayoutEffect but that bigass warning box that says useLayoutEffect and server-side render don't play nicely make me think this might be a better approach
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.
Looks like this won't work. The height is set based on the Resizer size, there's always a delay and the TextField scrolling is janky. Needed to add useLayoutEffect in the Resizer.
9863ab3
to
a194387
Compare
We noticed that this PR either modifies or introduces usage of the dangerouslySetInnerHTML attribute, which can cause cross-site scripting (XSS) vulnerabilities. Our team will take a look soon, but for now we recommend reviewing your code to ensure that this is what you intended to use and that there is not a safe alternative available. Docs are available here. |
This was not added as part of 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.
All looks good so far. Two comments on hook usage then I'm happy. Would appreciate a second set of eyes from somebody else though :)
Two opportunities to use new hooks - the useToggle one might be borderline and based on its usage might not be worth it, but using useUniqueId shall certainly be worth it
}: TextFieldProps) { | ||
const intl = useI18n(); | ||
const [height, setHeight] = useState<number | null>(null); | ||
const [focus, setFocus] = useState(focused || false); |
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.
Would it make sense use useToggle or useForcibleToggle?
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 was wondering the same. Looks like it's essentially just an extra level of abstraction that is less explicit, and in the end does the same?
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 could probably initialize this as useState(focused)
or if we wanted to keep the type as boolean we usually use the boolean constructor useState(Boolean(focused))
, no strong opinions
const [focus, setFocus] = useState(focused || false); | ||
|
||
const generatedId = useRef(getUniqueID()); | ||
const id = idProp || generatedId.current; |
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.
Use useUniqueId
, and you can remove the getUniqueID
function and generatedId.
const id = useUniqueId('TextField', idProp);
9783dc9
to
c1e5d9a
Compare
c1e5d9a
to
f577dbb
Compare
}; | ||
}, [currentHeight, onHeightChange]); | ||
|
||
useLayoutEffect(() => { |
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.
Need to use this so that the height gets adjusted before committing.
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.
useLayoutEffect
doesn't play well SSR, we'll probably want to rethink this -> https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
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 opted for his second option since useEffect
didn't work for us.
|
||
if (newHeight !== currentHeight) { | ||
onHeightChange(newHeight); | ||
} | ||
}); | ||
}; | ||
}, [currentHeight, onHeightChange]); |
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.
This is going to result in creating a new function every time the currentHeight changes. If we store currentHeight in a ref then I think we'd be able to take it out of the dependency array and thus avoid recreating the 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.
If we store currentHeight in a ref then I think we'd be able to take it out of the dependency array and thus avoid recreating the function
Your correct 😄 the reference to the ref will never be stale, just make sure not to deconstruct the value off the ref or that'll have to become a dependency
Sorry Dan I didn't get to this today, I'll 🎩 and review in the morning! |
a55fee1
to
1d7fe03
Compare
import {CircleCancelMinor} from '@shopify/polaris-icons'; | ||
import {VisuallyHidden} from '../VisuallyHidden'; | ||
import {classNames, variationName} from '../../utilities/css'; | ||
|
||
import {useI18n} from '../../utilities/i18n'; | ||
import {useMountedRef} from '../../utilities/mounted-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.
same as @shopify/react-hooks
expect(spy).toHaveBeenCalledWith(true); | ||
}); | ||
|
||
it('returns a ref with current value as false when the component is un-mounted', async () => { |
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.
This is the same test used in @shopify/react-hooks except we are getting a warning because of the promise.
Warning: The callback passed to ReactTestUtils.act(...) function must not return anything.
Any suggestions on how we can test that unmounting returns false without it?
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.
Got this to work, thoughts?
`
it('returns a ref with current value as false when the component is un-mounted', () => {
const spy = jest.fn((_) => {});
function MockComponent() {
const mounted = useMountedRef();
useEffect(() => {
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
spy(mounted.current);
};
}, [mounted]);
return <div />;
}
const mockComponent = mount(<MockComponent />);
mockComponent.unmount();
expect(spy).toHaveBeenCalledWith(false);
});
`
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.
Are our type files up to date?
No problem. I applied @BPScott suggestions and yours and brought over |
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.
Looks good! Have a concern about the useMountedRef though
@@ -2,7 +2,7 @@ import React from 'react'; | |||
import {mountWithAppProvider, findByTestID} from 'test-utilities/legacy'; |
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.
Any chance you can use react-testing
? We're trying not to use the legacy stuff anymore and eventually migrate our old tests away. Since you already wrote the tests though, don't feel like you have to go back and change them though.
src/utilities/mounted-ref.ts
Outdated
import {useRef, useEffect} from 'react'; | ||
|
||
export function useMountedRef() { | ||
const mounted = useRef(true); |
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.
Should this be false? 🤔
const mounted = useRef(true); | |
const mounted = useRef(false); |
src/utilities/mounted-ref.ts
Outdated
|
||
useEffect(() => { | ||
return () => { | ||
mounted.current = false; |
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.
Should this be true?? 🤔
mounted.current = false; | |
mounted.current = true; |
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.
Maybe I'm missing something?
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.
This one makes sense that it's false because it's for the clean-up?
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.
🤦♂ Didn't see it was in the returned function 😄
@@ -0,0 +1,72 @@ | |||
import React from 'react'; | |||
import {mount} from '@shopify/react-testing'; |
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.
import {mount} from '@shopify/react-testing'; | |
import {mount} from 'test-utilities'; |
expect(spy).toHaveBeenCalledWith(true); | ||
}); | ||
|
||
it('returns a ref with current value as false when the component is un-mounted', async () => { |
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.
Are our type files up to date?
if (this.animationFrame) { | ||
cancelAnimationFrame(this.animationFrame); | ||
useEffect(() => { | ||
return () => { |
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.
If we moved the cleanup to useLayoutEffect
we could kill this hook
}: TextFieldProps) { | ||
const intl = useI18n(); | ||
const [height, setHeight] = useState<number | null>(null); | ||
const [focus, setFocus] = useState(focused || false); |
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 could probably initialize this as useState(focused)
or if we wanted to keep the type as boolean we usually use the boolean constructor useState(Boolean(focused))
, no strong opinions
if (!inputRef.current) return; | ||
if (focused) { | ||
inputRef.current.focus(); | ||
} else if (focused === false) { |
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.
Right now if focused isn't true, it'll be false, won't it? We can probably kill this else if with a return in the if statement or an else or a ternary (focused ? ...focus() : ... blur())
|
||
const handleButtonRelease = useCallback(() => { | ||
clearTimeout(buttonPressTimer.current); | ||
}, [buttonPressTimer]); |
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 kill this as a dependency
}, [buttonPressTimer]); | |
}, []); |
|
||
const resizer = multiline ? ( | ||
const resizer = | ||
multiline && mounted.current ? ( |
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'm a little concerned about this, since in the hook when useEffect runs this will evaluate as false... but I could be being an airhead 😄
1d7fe03
to
0642c4d
Compare
0642c4d
to
ed0ffa7
Compare
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.
Pushed up a small change, LGTM 👍
WHY are these changes introduced?
Part of #1995
WHAT is this pull request doing?
change
TextField
andResizer
to use react hooksHow to 🎩
🖥 Local development instructions
🗒 General tophatting guidelines
📄 Changelog guidelines
Copy-paste this code in
playground/Playground.tsx
:🎩 checklist
README.md
with documentation changes