Skip to content
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

Merged
merged 3 commits into from
Sep 17, 2019

Conversation

dleroux
Copy link
Contributor

@dleroux dleroux commented Aug 20, 2019

WHY are these changes introduced?

Part of #1995

WHAT is this pull request doing?

change TextField and Resizer to use react hooks

How to 🎩

🖥 Local development instructions
🗒 General tophatting guidelines
📄 Changelog guidelines

  • Loading the Textfield examples in the playground and ensuring they all behave as expected should be sufficient.
Copy-paste this code in playground/Playground.tsx:
import React from 'react';
import {Page} from '../src';

export default function Playground() {
  return (
    <Page title="Playground">
      {/* Add the code you want to test in here */}
    </Page>
  );
}

🎩 checklist

@dleroux dleroux force-pushed the Textfield-as-function branch 2 times, most recently from 979fd9c to 881e844 Compare August 21, 2019 12:30
@dleroux dleroux force-pushed the Textfield-as-function branch from 881e844 to 9863ab3 Compare August 30, 2019 17:20
@dleroux dleroux requested a review from amrocha August 30, 2019 17:57
@@ -29,7 +29,7 @@ $stacking-order: (
flex-wrap: wrap;

> .Input {
overflow: auto;
overflow: hidden;
Copy link
Contributor Author

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.

Copy link
Member

@BPScott BPScott Sep 12, 2019

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

Copy link
Contributor Author

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.

@dleroux dleroux force-pushed the Textfield-as-function branch from 9863ab3 to a194387 Compare September 12, 2019 12:11
@dleroux dleroux changed the title [WIP][TextField] Converting TextField to a functional component [TextField] Converting TextField and Resizer to functional components using React Hooks Sep 12, 2019
@shopify-admins shopify-admins requested a review from a team September 12, 2019 12:25
@CautionTapeBot
Copy link

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.

@dleroux dleroux requested a review from BPScott September 12, 2019 12:25
@dleroux
Copy link
Contributor Author

dleroux commented Sep 12, 2019

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.

Copy link
Member

@BPScott BPScott left a 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);
Copy link
Member

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?

Copy link
Contributor Author

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?

Copy link
Member

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;
Copy link
Member

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);

@dleroux dleroux force-pushed the Textfield-as-function branch 2 times, most recently from 9783dc9 to c1e5d9a Compare September 13, 2019 13:36
@BPScott BPScott mentioned this pull request Sep 13, 2019
@dleroux dleroux force-pushed the Textfield-as-function branch from c1e5d9a to f577dbb Compare September 16, 2019 14:31
};
}, [currentHeight, onHeightChange]);

useLayoutEffect(() => {
Copy link
Contributor Author

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.

Copy link
Member

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

Copy link
Contributor Author

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.

@dleroux dleroux requested a review from BPScott September 16, 2019 14:34

if (newHeight !== currentHeight) {
onHeightChange(newHeight);
}
});
};
}, [currentHeight, onHeightChange]);
Copy link
Member

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

Copy link
Member

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

@AndrewMusgrave
Copy link
Member

Sorry Dan I didn't get to this today, I'll 🎩 and review in the morning!

@dleroux dleroux force-pushed the Textfield-as-function branch 3 times, most recently from a55fee1 to 1d7fe03 Compare September 17, 2019 14:08
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';
Copy link
Contributor Author

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 () => {
Copy link
Contributor Author

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?

Copy link
Contributor Author

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);

});
`

Copy link
Member

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?

@dleroux
Copy link
Contributor Author

dleroux commented Sep 17, 2019

Sorry Dan I didn't get to this today, I'll 🎩 and review in the morning!

No problem. I applied @BPScott suggestions and yours and brought over useMountedRef . Left you a question regarding the test.

@dleroux dleroux requested a review from BPScott September 17, 2019 14:30
Copy link
Member

@AndrewMusgrave AndrewMusgrave left a 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';
Copy link
Member

@AndrewMusgrave AndrewMusgrave Sep 17, 2019

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.

import {useRef, useEffect} from 'react';

export function useMountedRef() {
const mounted = useRef(true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be false? 🤔

Suggested change
const mounted = useRef(true);
const mounted = useRef(false);


useEffect(() => {
return () => {
mounted.current = false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be true?? 🤔

Suggested change
mounted.current = false;
mounted.current = true;

Copy link
Member

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?

Copy link
Contributor Author

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?

Copy link
Member

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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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 () => {
Copy link
Member

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 () => {
Copy link
Member

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 useLayoutEffectwe could kill this hook

}: TextFieldProps) {
const intl = useI18n();
const [height, setHeight] = useState<number | null>(null);
const [focus, setFocus] = useState(focused || false);
Copy link
Member

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) {
Copy link
Member

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]);
Copy link
Member

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

Suggested change
}, [buttonPressTimer]);
}, []);


const resizer = multiline ? (
const resizer =
multiline && mounted.current ? (
Copy link
Member

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 😄

@dleroux dleroux force-pushed the Textfield-as-function branch from 1d7fe03 to 0642c4d Compare September 17, 2019 16:52
@dleroux dleroux force-pushed the Textfield-as-function branch from 0642c4d to ed0ffa7 Compare September 17, 2019 19:32
Copy link
Member

@AndrewMusgrave AndrewMusgrave left a 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 👍

@dleroux dleroux merged commit 0b9938e into master Sep 17, 2019
@dleroux dleroux deleted the Textfield-as-function branch September 17, 2019 21:33
@dleroux dleroux temporarily deployed to production September 20, 2019 17:44 Inactive
@dleroux dleroux temporarily deployed to production September 23, 2019 15:28 Inactive
@dleroux dleroux temporarily deployed to production September 23, 2019 15:38 Inactive
@dleroux dleroux temporarily deployed to production September 23, 2019 16:59 Inactive
@dleroux dleroux temporarily deployed to production September 23, 2019 17:05 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants