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

Refactor some components to match the react-hooks/exhaustive-deps eslint rules #25064

Conversation

kevin940726
Copy link
Member

Description

As per #24914, we try to refactor some components to match the react-hooks/exhaustive-deps eslint rules to compare the outcome and efforts.

Refactored <BlockListBlock> and <RichText> to demonstrate the efforts.

<RichText> is tricky, the original style does not match the mental model of hooks, hence lots of violation to the eslint rules. To preserve backward-compatibility and to prevent ground-up rewrites, I chose to introduce three utility hooks in @wordpress/compose to help us migrate to adopt the eslint rules. By far, it's the most difficult component to refactor, others seem pretty straightforward like <BlockListBlock>.

How has this been tested?

  • npm run test-unit

Types of changes

Bug fix

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

@kevin940726 kevin940726 added the [Type] Code Quality Issues or PRs that relate to code quality label Sep 4, 2020
@github-actions
Copy link

github-actions bot commented Sep 4, 2020

Size Change: +272 B (0%)

Total Size: 1.17 MB

Filename Size Change
build/block-editor/index.js 126 kB +11 B (0%)
build/components/index.js 200 kB -3 B (0%)
build/compose/index.js 9.81 kB +133 B (1%)
build/core-data/index.js 12.3 kB -1 B
build/data/index.js 8.55 kB +1 B
build/edit-widgets/index.js 11.9 kB -1 B
build/rich-text/index.js 14 kB +133 B (0%)
build/url/index.js 4.06 kB -1 B
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/annotations/index.js 3.67 kB 0 B
build/api-fetch/index.js 3.44 kB 0 B
build/autop/index.js 2.82 kB 0 B
build/blob/index.js 620 B 0 B
build/block-directory/index.js 7.99 kB 0 B
build/block-directory/style-rtl.css 953 B 0 B
build/block-directory/style.css 952 B 0 B
build/block-editor/style-rtl.css 10.8 kB 0 B
build/block-editor/style.css 10.8 kB 0 B
build/block-library/editor-rtl.css 8.52 kB 0 B
build/block-library/editor.css 8.52 kB 0 B
build/block-library/index.js 136 kB 0 B
build/block-library/style-rtl.css 7.47 kB 0 B
build/block-library/style.css 7.47 kB 0 B
build/block-library/theme-rtl.css 741 B 0 B
build/block-library/theme.css 742 B 0 B
build/block-serialization-default-parser/index.js 1.88 kB 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/blocks/index.js 47.7 kB 0 B
build/components/style-rtl.css 15.7 kB 0 B
build/components/style.css 15.7 kB 0 B
build/data-controls/index.js 1.29 kB 0 B
build/date/index.js 5.38 kB 0 B
build/deprecated/index.js 772 B 0 B
build/dom-ready/index.js 568 B 0 B
build/dom/index.js 4.48 kB 0 B
build/edit-navigation/index.js 11.7 kB 0 B
build/edit-navigation/style-rtl.css 1.16 kB 0 B
build/edit-navigation/style.css 1.16 kB 0 B
build/edit-post/index.js 304 kB 0 B
build/edit-post/style-rtl.css 5.61 kB 0 B
build/edit-post/style.css 5.61 kB 0 B
build/edit-site/index.js 17 kB 0 B
build/edit-site/style-rtl.css 3.06 kB 0 B
build/edit-site/style.css 3.06 kB 0 B
build/edit-widgets/style-rtl.css 2.45 kB 0 B
build/edit-widgets/style.css 2.45 kB 0 B
build/editor/editor-styles-rtl.css 492 B 0 B
build/editor/editor-styles.css 493 B 0 B
build/editor/index.js 45.3 kB 0 B
build/editor/style-rtl.css 3.81 kB 0 B
build/editor/style.css 3.81 kB 0 B
build/element/index.js 4.65 kB 0 B
build/escape-html/index.js 733 B 0 B
build/format-library/index.js 7.71 kB 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/hooks/index.js 2.13 kB 0 B
build/html-entities/index.js 621 B 0 B
build/i18n/index.js 3.57 kB 0 B
build/is-shallow-equal/index.js 710 B 0 B
build/keyboard-shortcuts/index.js 2.52 kB 0 B
build/keycodes/index.js 1.94 kB 0 B
build/list-reusable-blocks/index.js 3.12 kB 0 B
build/list-reusable-blocks/style-rtl.css 476 B 0 B
build/list-reusable-blocks/style.css 476 B 0 B
build/media-utils/index.js 5.32 kB 0 B
build/notices/index.js 1.79 kB 0 B
build/nux/index.js 3.4 kB 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/plugins/index.js 2.56 kB 0 B
build/primitives/index.js 1.41 kB 0 B
build/priority-queue/index.js 789 B 0 B
build/redux-routine/index.js 2.85 kB 0 B
build/server-side-render/index.js 2.77 kB 0 B
build/shortcode/index.js 1.7 kB 0 B
build/token-list/index.js 1.27 kB 0 B
build/viewport/index.js 1.85 kB 0 B
build/warning/index.js 1.14 kB 0 B
build/wordcount/index.js 1.17 kB 0 B

compressed-size-action

Copy link
Contributor

@youknowriad youknowriad left a comment

Choose a reason for hiding this comment

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

Thanks for starting these efforts. RichText is one of the most complex components we have. I'd appreciate @ellatrix's eyes here.

blockType.title,
wrapperProps,
]
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This is basically the same thing, too bad that we're forced to do that due to the lint rule.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, it's a bummer, we can create a custom hook for this, but I doubt that it's worth it. It doesn't actually hurt to just write it like this anyway, at least it makes the compiler happy 😅

Copy link
Member

Choose a reason for hiding this comment

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

Are we forced to use that rule?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's the whole point of this PR though 😅. IMO, The benefits outweighs the inconveniences.

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 wanted to, we could use a /* eslint-disable RULE-NAME */ for this particular use-case, with a comment explaining why.

@@ -165,6 +175,22 @@ _Parameters_
- _callback_ `Function`: Shortcut callback.
- _options_ `WPKeyboardShortcutConfig`: Shortcut options.

<a name="useLazyRef" href="#useLazyRef">#</a> **useLazyRef**
Copy link
Contributor

Choose a reason for hiding this comment

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

That one is a bit confusing, it's very unclear what it does and when to use it. Makes me wonder if it deserves to be a public API on the compose package.

Copy link
Member Author

Choose a reason for hiding this comment

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

In my own experience, I find useLazyRef very useful in many places though. Whenever we want to create a ref, but only want to create it lazily once, this hook is the way to go.

This hook is basically giving useRef the same super power of useState function initialization.

useState(() => expensiveCalculation())[0]; // returns a value

useLazyRef(() => expensiveCalculation()); // returns a ref

Copy link
Member

Choose a reason for hiding this comment

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

"Lazy" sounds a bit weird. Could we call it useMemoizedRef or something like that? I don't see much benefit though. useRef( useMemo() ) is the same thing?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not memoized though, and it's also not the same as useRef(useMemo()). It only lazily initialize once in the first render.
The name is inspired by this tweet by Dan

Copy link
Member

Choose a reason for hiding this comment

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

Sounds good. How can we make it clearer that the laziness only applies to initial value?

Copy link
Member

Choose a reason for hiding this comment

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

Makes me wonder if there's ever a use case for the initial ref to be set on every 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.

Nope, not that I can think of 😅. Ref is always only be set on the initial render, it's like the instance variable of class components.

Copy link
Member

Choose a reason for hiding this comment

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

Right, so I wonder why we don't do this by default (or why React doesn't).

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it's because that function initializer still adds some overheads to the component, even though it's really small, it will still be used by a large number of projects. useRef(value) is also way simpler as a public API too.

* @param {Function} effect The effect callback passed to `useEffect`.
* @param {Array} deps The dependency array that is compared against shallowly.
*/
function useShallowCompareEffect( effect, deps ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This hook is a bit confusing what's the difference between doing this and spreading deps in the dependencies?

Copy link
Member Author

@kevin940726 kevin940726 Sep 10, 2020

Choose a reason for hiding this comment

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

The main difference is that in the original effect, we are passing a dependency array dynamically, which means the size of the array could change. When it changes, it will throw a warning from React like below.

Warning: The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant.

This hook hack around this by putting them inside an item in the dependency array, so that even when the size changed, it won't throw warnings at us.

@@ -137,6 +135,63 @@ function fixPlaceholderSelection( defaultView ) {
selection.collapseToStart();
}

function useReapply( {
Copy link
Member

Choose a reason for hiding this comment

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

What's the purpose of splitting this out? What rule is it satisfying?

Copy link
Member Author

Choose a reason for hiding this comment

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

Just want to group them all together so that it's clear that they are for the same logic.


shouldReapplyRef.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.

Missing dependencies?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's actually intended. No dependencies means that the effect will run after every render, so that we don't have to add applyFromProps to the dependency array. applyFromProps changes almost every render anyway, so it basically has the same effect.

Copy link
Member

Choose a reason for hiding this comment

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

In that case it might be good to comment, since missing dependencies rare and others might be wondering if it's intentional or a mistake.

Copy link
Member

Choose a reason for hiding this comment

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

What's wrong with calling applyFromProps() from the other effects?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, a comment would be helpful!

If we want to call applyFromProps in other effects, it has to be in the dependency array of that effect. However, since applyFromProps almost always changes, the effect will always fire too. One solution would be to split them up into different effects and register another effect to run applyFromProps (like this solution). Another solution would be to rewrite them to better match the mental modal of hooks, but that's more work and could potentially be a breaking change if we're not careful.

* @param {Function} initializer A function to return the ref object.
* @return {MutableRefObject} The returned ref object.
*/
function useLazyRef( initializer ) {
Copy link
Member

Choose a reason for hiding this comment

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

Why not use useRef( useMemo() ) everywhere?

Copy link
Member Author

Choose a reason for hiding this comment

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

useMemo has to be passed with dependencies array, and also it's not guaranteed to be run only once.

@kevin940726 kevin940726 marked this pull request as ready for review September 16, 2020 00:25
@kevin940726 kevin940726 merged commit 4ac85a8 into update/eslint-react-hooks-exhaustive-deps Sep 21, 2020
@kevin940726 kevin940726 deleted the update/refactor-hook-to-match-eslint-rules branch September 21, 2020 05:52
*
* @param {Function} effect The effect callback passed to `useEffect`.
*/
function useDidMount( effect ) {
Copy link
Member

Choose a reason for hiding this comment

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

I think useMountEffect or useEffectOnMount would be more descriptive names for this hook. I also don't think we should say it's "only used for backward-compatibility reason". There are plenty of valid use-cases to run something only once upon mount.

Copy link
Member

Choose a reason for hiding this comment

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

Perhaps we should also create two versions of this hook... one for useLayoutEffect and one of useEffect. E.g. useMountLayoutEffect or useLayoutEffectOnMount. (I think the latter reads more clearly.)

Copy link
Member Author

Choose a reason for hiding this comment

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

I disagree though, 99% of cases we can just use useEffect with an empty dependency array to achieve the same thing. If we're writing a new component, then I believe we can avoid using this hook if we can "think in hooks" in most cases. Besides, we can always adjust the wording if it does become more and more used!

Same thing goes for creating two versions of this hook. We can always create them if necessary. I find these hooks more like utility hooks we can share between packages, but not like a standalone hooks library that's going to cover all cases. That's just my take though, happy to be proven wrong :)!

Copy link
Member Author

Choose a reason for hiding this comment

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

Just want to share this tweet from React core team to back up my reasoning that making useMount more exposed might not be a good idea: https://twitter.com/sebmarkbage/status/1313842495256748038?s=21

Copy link
Member

Choose a reason for hiding this comment

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

Thanks. Another React dev tweet worth sharing: https://twitter.com/dan_abramov/status/1284510166503890944

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants