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

i18n-calypso: add useTranslate hook #31043

Merged
merged 9 commits into from
Mar 5, 2019
Merged

i18n-calypso: add useTranslate hook #31043

merged 9 commits into from
Mar 5, 2019

Conversation

jsnajdr
Copy link
Member

@jsnajdr jsnajdr commented Feb 26, 2019

The second React hook in Calypso 🎉 (after #31035)

useTranslate is a hook version of the localize HOC that provides the translate function and the localeSlug string, nothing else.

Adding also first use in blocks/post-share/no-connections-notice. See #31022 for instructions where to find it and how to test it.

Still work in progress, need to add tests (I have no idea how to test hooks at this moment) and documentation.

@matticbot
Copy link
Contributor

@jsnajdr
Copy link
Member Author

jsnajdr commented Feb 27, 2019

This is now ready for review. I did some optimizations to prevent creating unneeded objects on every hook call. Added section about the hook to README. And added a basic unit test.

I'd also love to test the reactive rerenders of useTranslate on locale change, but don't know how to do that. The React hooks unit testing story is still in early phases. Enzyme doesn't have support for hooks. Howto articles on the web are usually very long and complicated 😄 I gave up for now.

@jsnajdr jsnajdr added [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. and removed [Status] In Progress labels Feb 27, 2019
@ockham
Copy link
Contributor

ockham commented Feb 27, 2019

Sidenote, could this be relevant for Gutenberg? See WordPress/gutenberg#9846 specifically.

Copy link
Contributor

@sgomes sgomes left a comment

Choose a reason for hiding this comment

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

LGTM, great seeing hooks making their way into the codebase :) I left some comments for you to consider.

const onChange = () => setLocaleSlug( getLocaleSlug() );
i18n.on( 'change', onChange );
return () => i18n.off( 'change', onChange );
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

The empty array is an important detail here to make sure that the effect is only applied on mount, and not on every update. Good work 👍

return () => i18n.off( 'change', onChange );
}, []);

return [ translate, localeSlug ];
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure I'd use an array here; the two properties aren't as closely-related as the [ property, setProperty ] pair returned by useState, and they likely won't need to be renamed either. I'd probably return an object instead.

Still, this is really a matter of personal taste here, so happy either way.

Copy link
Member Author

Choose a reason for hiding this comment

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

Another option is to just return translate:

const translate = useTranslate();

Components never really use anything else. The localeSlug needs to be part of state, because the translate function is always the same and something needs to change to trigger a rerender. That will be especially true if we switch to React Context instead of one i18n listener per each mounted localized component.

I returned array mainly because it's an existing convention for several hooks. But thinking about it a little more, it doesn't look like a good fit for this particular hook.

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 to just returning translate

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 be possible to make the language slug available as translate.localeSlug?

renderer.render( <Label /> );

// check that it's translated
expect( renderer.getRenderOutput() ).toBe( 'háček (cs)' );
Copy link
Contributor

Choose a reason for hiding this comment

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

Mocking i18n might allow you to test reactivity for this hook. Take a look at the react-helpers.js test in https://github.com/Automattic/wp-calypso/pull/31081/files for a similar situation where I mocked a browser API.

const [ localeSlug, setLocaleSlug ] = React.useState( getLocaleSlug );

React.useEffect(() => {
const onChange = () => setLocaleSlug( getLocaleSlug() );
Copy link
Contributor

@sgomes sgomes Feb 27, 2019

Choose a reason for hiding this comment

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

I ran into some confusing issues in my own PR when dealing with subscriptions for multiple instances of the same component. This code looks good, but I'd suggest testing reactivity when you have multiple instances of the same component, and ensure they all get updated correctly.

const getLocaleSlug = i18n.getLocaleSlug.bind( i18n );

return function useTranslate() {
const [ localeSlug, setLocaleSlug ] = React.useState( getLocaleSlug );
Copy link
Contributor

@blowery blowery Feb 28, 2019

Choose a reason for hiding this comment

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

See https://reactjs.org/docs/hooks-reference.html#lazy-initial-state for what passing a function to useState means. TL;DR: it calls the function to generate the first state, only calling it on the first render

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's exactly what we need here, isn't it? The first render will initialize the state value, and all further changes come from the change listener.

Copy link
Contributor

Choose a reason for hiding this comment

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

yup! I just found it surprising, as I didn't know that useState could take an initializer function. Sorry I wasn't more clear.

Copy link
Contributor

Choose a reason for hiding this comment

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

my first comment should have read: "A note to anyone else reviewing this PR, see ..."

Copy link
Member Author

Choose a reason for hiding this comment

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

Here's a great blog post that describes many possible gotchas: https://overreacted.io/making-setinterval-declarative-with-react-hooks/

One source of subtle bugs is capturing a stale value inside a closure:

const [ count, setCount ] = useState( 0 );
useEffect( () => {
  setTimeout( () => setCount( count + 1 ), 1000 );
}, [] );

Because of the [] argument, useEffect will call the effect function only once, on mount, and the count value will remain bound to 0, even if the state changed in the meantime.

It's interesting that the setCount function doesn't have this problem and can be saved without problems. It's assumed to be the same for every call. Ref objects returned from useRef() can also be saved. I don't know what exactly the rules and guarantees are -- which values are safe and which are not?

@jsnajdr
Copy link
Member Author

jsnajdr commented Feb 28, 2019

I modified the hook to just return translate function, and to expose a translate.localeSlug (as a getter defined with Object.defineProperty) as @akirk suggested.

The withLocalizedMoment should get the same update if we agree it's the way to go.

As a next step, I'll look into @sgomes' suggestions on reactivity and its testing.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 4, 2019

I added some unit tests that test reactive rerenders when the i18n object emits a change event. Thanks @sgomes for teaching me how to do it in #31081.

There was one subtle bug that I had to fix: i18n.setLocale can be used not only to switch to another locale, but also to update translations for the current locale. Our community translator does that, for example.

Because the localeSlug state bit stayed the same after such update, the components using useTranslate were not rerendered. There was no state update to trigger that and this.setState( {} ) no longer does that with hooks.

The solution is to create a new instance of the bound translate function on each change event.

We should eventually switch to React Context that creates just one instance of the translate function on each update and passes it down. Currently, we maintain 100+ event listeners instead of one, and now will also create 100+ function instances. Fortunately, JS engine authorities say that bound functions are extremely cheap to create and store.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 5, 2019

Sidenote, could this be relevant for Gutenberg? See WordPress/gutenberg#9846 specifically.

@ockham For a solution for the component interpolation problem specifically? Or for something else?

This PR takes the i18n-calypso API with no modifications at all and just adds a new React helper.

I'd like to learn more about the differences between @wordpress/i18n and i18n-calypso and ways how to reconcile them into one system, but at this moment, I know very little about that.

@jsnajdr jsnajdr merged commit ad3e868 into master Mar 5, 2019
@jsnajdr jsnajdr deleted the add/use-translate-hook branch March 5, 2019 13:40
@matticbot matticbot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Mar 5, 2019
@ockham
Copy link
Contributor

ockham commented Mar 8, 2019

@ockham For a solution for the component interpolation problem specifically?

Yeah, for that. Sorry, I should've been clearer.

This PR takes the i18n-calypso API with no modifications at all and just adds a new React helper.

Yeah, it was mostly the React helper I thought might be a good fit for Gutenberg.

I'd like to learn more about the differences between @wordpress/i18n and i18n-calypso and ways how to reconcile them into one system, but at this moment, I know very little about that.

Yep, let's discuss that some time (maybe after we've finished moving the JP blocks code to the JP repo 😅 )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants