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

InputControl: Fix undo when changing padding values #40518

Merged
merged 11 commits into from
May 30, 2022

Conversation

youknowriad
Copy link
Contributor

Discovered in #40505

What?

This PR solves an issue we have in InputField component where it the component receives a new "value" prop, instead of updating its internal state with the new value, it actually does the opposite, it triggers onChange with the old value, causing a potential infinite loop.

Why?

Undo is broken if you change values in the padding control using "drag". So this is a bug fix.

Notes

I've found all the nested chain of components UnitControl -> ValueInput -> NumberControl -> InputField to be too complex. I wonder if all that complexity is worth for a component that is supposed to be simple.

My changes are breaking unit tests. I think it's probably other components (RangeControl and UnitControl) that are misusing InputField but I may be wrong here, I might need some help here @ciampo

Testing Instructions

1- Add a group block
2- Click the "padding" input in the sidebar
3- Click and drag the input to change the value
4- While leaving the focus on the input, click "ctrl + z" to undo the changes
5- You'll notice that it undos but very quickly restores to the modified value in trunk
6- in this branch, it undos the value properly

Screenshots or screencast

@youknowriad youknowriad added the [Type] Bug An existing feature does not function as intended label Apr 21, 2022
@mtias mtias added the [Feature] UI Components Impacts or related to the UI component system label Apr 21, 2022
@ciampo
Copy link
Contributor

ciampo commented Apr 22, 2022

Hey @youknowriad , thank you for the ping.

Undo is broken if you change values in the padding control using "drag"
...
the component receives a new "value" prop, instead of updating its internal state with the new value, it actually does the opposite, it triggers onChange with the old value, causing a potential infinite loop.

I can definitely reproduce the bug, and your exploration is very useful in understanding what's causing it.

I've found all the nested chain of components UnitControl -> ValueInput -> NumberControl -> InputField to be too complex. I wonder if all that complexity is worth for a component that is supposed to be simple.

Just to clarify, the actual chain of components is UnitControl > NumberControl > InputControl. Other components like ValueInput and InputField are internal components that should be considered as implementation details.

Having said that, I also personally find that the way InputControl handles its internal state updates is very complicated, and feels over-engineered (it even has an internal reducer).

To give some more context (even though I wasn't around when this component was initially created), I believe that this complex logic was written this way in order to:

  • support controlled/uncontrolled modes
  • support the isPressEnterToChange mode (basically, the new value being typed is not "committed" until the Enter key is pressed)
  • support drag gestures
  • support various edge cases (like pressing esc, up/down arrows, blur events...)
  • allow extensibility for higher level components (for example, NumberControl can add its specific number validation)

My changes are breaking unit tests. I think it's probably other components (RangeControl and UnitControl) that are misusing InputField but I may be wrong here

I don't think they're using it wrong . To me, it feels quite the opposite — some of these tests are very specific on the behaviour is expected.

I'll try to take a deeper look at the code and see if I can come up with a fix that doesn't break the tests. In case this fix was urgent, I wonder if disabling the drag gestures for the time being could be considered an option ?

For the medium-long term, I would personally really like to simplify this component — understand if certain features can be removed and/or simplified (e.g. isPressEnterToChange, as discussed here, and the drag gestured), and if we can lean more on native browser behaviour instead or maintaining some complex logic. But it won't be an easy task and it will likely cause breaking changes.


cc'ing also a few folks who have worked on this component recently to collect a few more opinions — @mirka @stokesman @andrewserong @aaronrobertshaw

@stokesman
Copy link
Contributor

To reproduce this you don't have to drag to change the value or even use the input itself. The main thing is that the the input is focused after a change. A great example would be the Spacer block. Drag the block’s in-canvas resize control, then focus the input that controls the height. Then try undo and note that it won't work. The same applies for redo as well.

It might help to clarify the fundamental change in this branch is that InputControl (and therefore (Number|Unit|Box)Control) are now controllable whether or not their input is focused. In trunk they are only controllable while their input is not focused.

This change is the only way I can imagine to fix the bug and due to the tight coupling of some other components they require changes as well. I've opened up #40568 with what seems a good start on finishing this up. We can merge to this branch if it looks good. I might have some comments or changes to add there after I test some more.

@youknowriad
Copy link
Contributor Author

It might help to clarify the fundamental change in this branch is that InputControl (and therefore (Number|Unit|Box)Control) are now controllable whether or not their input is focused. In trunk they are only controllable while their input is not focused.

My reasoning is that the components should be always controlled if a "value" prop is provided. If the "value" prop is undefined, the components should be uncontrolled. Is that what you meant with "controllable"?

@stokesman
Copy link
Contributor

I agree with that reasoning and was trying to point out how the component’s exception to being controlled while focused is the condition in which the bug presents. I figured you understood as much, but wrote it in case it would help anyone else having a look here.

Also, If it wasn't clear, I support this change 💯.

@ciampo
Copy link
Contributor

ciampo commented Apr 25, 2022

Thank you @stokesman for helping out! The changes proposed in your PR make sense to me, and I'd be in favour or merging it into this PR as soon as it's ready.

We'd be then able to give a final round of review to this PR and hopefully merge the fix into trunk

Copy link
Contributor

@aaronrobertshaw aaronrobertshaw 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 the hard work on this @youknowriad and @stokesman 👍

I've given this a bit of a test now #40568 has been merged into this PR.

✅ Original issue could be replicated on trunk
✅ Issue has been resolved after applying this PR
✅ Tested Input, Range and Unit controls via editor and Storybook
✅ Unit tests pass for affected controls and no typing errors

There was one edge case with the RangeControl I encountered that I don't believe has been handled yet.

To replicate:

  1. Fire up the Storybook, visit the RangeControl page, and in the controls allow resets and set an initial position
  2. Manually type a value in the input field well above the max limit
  3. Without pressing enter or switching focus elsewhere, click the reset button
  4. Notice the slider position is correct but the input field is showing the clamped max value
  5. Focus the input field and note that it now displays the correct value
Screen.Recording.2022-05-16.at.4.07.14.pm.mp4

I don't think this is a blocker to this PR and could be addressed separately.

@stokesman
Copy link
Contributor

Thanks for testing and great catch of that reset issue @aaronrobertshaw. I agree it could be addressed separately as it's a definite edge case and no RangeControls in the repo even allowReset. I also experimented with a fix and it’s pretty simple (the bulk of changes are formatting).

Update unimpeded ranged entry hook to account for blur event
diff --git a/packages/components/src/range-control/index.js b/packages/components/src/range-control/index.js
index 4018d37cfa..606d53895f 100644
--- a/packages/components/src/range-control/index.js
+++ b/packages/components/src/range-control/index.js
@@ -139,15 +139,14 @@ function RangeControl(
 				isResetPendent.current = true;
 			}
 		},
+		onBlur: () => {
+			if ( isResetPendent.current ) {
+				handleOnReset();
+				isResetPendent.current = false;
+			}
+		},
 	} );
 
-	const handleOnInputNumberBlur = () => {
-		if ( isResetPendent.current ) {
-			handleOnReset();
-			isResetPendent.current = false;
-		}
-	};
-
 	const handleOnReset = () => {
 		const resetValue = parseFloat( resetFallbackValue );
 
@@ -269,7 +268,6 @@ function RangeControl(
 						disabled={ disabled }
 						inputMode="decimal"
 						isShiftStepEnabled={ isShiftStepEnabled }
-						onBlur={ handleOnInputNumberBlur }
 						shiftStep={ shiftStep }
 						step={ step }
 						{ ...someNumberInputProps }
diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js
index aab35cf09c..2984434080 100644
--- a/packages/components/src/range-control/utils.js
+++ b/packages/components/src/range-control/utils.js
@@ -20,7 +20,13 @@ import { useCallback, useRef, useEffect, useState } from '@wordpress/element';
  *
  * @return {Object} Assorted props for the input.
  */
-export function useUnimpededRangedNumberEntry( { max, min, onChange, value } ) {
+export function useUnimpededRangedNumberEntry( {
+	max,
+	min,
+	onBlur,
+	onChange,
+	value,
+} ) {
 	const ref = useRef();
 	const isDiverging = useRef( false );
 	/** @type {import('../input-control/types').InputChangeCallback}*/
@@ -32,6 +38,10 @@ export function useUnimpededRangedNumberEntry( { max, min, onChange, value } ) {
 		}
 		onChange( next );
 	};
+	const blurHandler = () => {
+		isDiverging.current = false;
+		onBlur?.();
+	};
 	// When the value entered in the input is out of range then a clamped value
 	// is sent through onChange and that goes on to update the input. In such
 	// circumstances this effect overwrites the input value with the entered
@@ -48,7 +58,14 @@ export function useUnimpededRangedNumberEntry( { max, min, onChange, value } ) {
 		}
 	}, [ value ] );
 
-	return { max, min, ref, value, onChange: changeHandler };
+	return {
+		max,
+		min,
+		ref,
+		value,
+		onBlur: blurHandler,
+		onChange: changeHandler,
+	};
 }
 
 /**
We can put that in here or I'd be glad to make it a follow up applicable to the branch in #40535.

Copy link
Contributor

@aaronrobertshaw aaronrobertshaw left a comment

Choose a reason for hiding this comment

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

Appreciate the quick fix for the reset issue @stokesman 🚀

I've retested this PR with the suggested patch and everything now appears to be functioning well. I can confirm the reset issue is resolved.

Screen.Recording.2022-05-17.at.10.33.21.am.mp4

I agree it could be addressed separately as it's a definite edge case and no RangeControls in the repo even allowReset

I did look to see if allowReset was used at all in Gutenberg. Despite it not being used, the RangeControl is exported as a stable component for 3rd party use. While we are changing the way it works under the hood it makes sense to me to include your suggested patch as well, particularly given its simplicity.

I'm happy to approve this with or without the patch fixing the reset behaviour. It would probably be a good idea to get a second set of eyes on this before merging though.

@stokesman stokesman force-pushed the fix/input-field-reset-behavior branch from c8a8013 to 5150ade Compare May 17, 2022 18:34
@stokesman
Copy link
Contributor

I'm happy to approve this with or without the patch fixing the reset behaviour

👍 Thanks again for testing!

I went ahead and

  • committed that reset patch b91a418
  • added a changelog entry
  • rebased on trunk

@github-actions
Copy link

Size Change: +131 B (0%)

Total Size: 1.24 MB

Filename Size Change
build/components/index.min.js 227 kB +131 B (0%)
ℹ️ View Unchanged
Filename Size
build/a11y/index.min.js 993 B
build/annotations/index.min.js 2.77 kB
build/api-fetch/index.min.js 2.27 kB
build/autop/index.min.js 2.15 kB
build/blob/index.min.js 487 B
build/block-directory/index.min.js 6.51 kB
build/block-directory/style-rtl.css 1.01 kB
build/block-directory/style.css 1.01 kB
build/block-editor/default-editor-styles-rtl.css 378 B
build/block-editor/default-editor-styles.css 378 B
build/block-editor/index.min.js 150 kB
build/block-editor/style-rtl.css 14.9 kB
build/block-editor/style.css 14.9 kB
build/block-library/blocks/archives/editor-rtl.css 61 B
build/block-library/blocks/archives/editor.css 60 B
build/block-library/blocks/archives/style-rtl.css 65 B
build/block-library/blocks/archives/style.css 65 B
build/block-library/blocks/audio/editor-rtl.css 150 B
build/block-library/blocks/audio/editor.css 150 B
build/block-library/blocks/audio/style-rtl.css 111 B
build/block-library/blocks/audio/style.css 111 B
build/block-library/blocks/audio/theme-rtl.css 125 B
build/block-library/blocks/audio/theme.css 125 B
build/block-library/blocks/avatar/editor-rtl.css 116 B
build/block-library/blocks/avatar/editor.css 116 B
build/block-library/blocks/avatar/style-rtl.css 59 B
build/block-library/blocks/avatar/style.css 59 B
build/block-library/blocks/block/editor-rtl.css 161 B
build/block-library/blocks/block/editor.css 161 B
build/block-library/blocks/button/editor-rtl.css 445 B
build/block-library/blocks/button/editor.css 445 B
build/block-library/blocks/button/style-rtl.css 560 B
build/block-library/blocks/button/style.css 560 B
build/block-library/blocks/buttons/editor-rtl.css 292 B
build/block-library/blocks/buttons/editor.css 292 B
build/block-library/blocks/buttons/style-rtl.css 275 B
build/block-library/blocks/buttons/style.css 275 B
build/block-library/blocks/calendar/style-rtl.css 207 B
build/block-library/blocks/calendar/style.css 207 B
build/block-library/blocks/categories/editor-rtl.css 84 B
build/block-library/blocks/categories/editor.css 83 B
build/block-library/blocks/categories/style-rtl.css 79 B
build/block-library/blocks/categories/style.css 79 B
build/block-library/blocks/code/style-rtl.css 103 B
build/block-library/blocks/code/style.css 103 B
build/block-library/blocks/code/theme-rtl.css 124 B
build/block-library/blocks/code/theme.css 124 B
build/block-library/blocks/columns/editor-rtl.css 108 B
build/block-library/blocks/columns/editor.css 108 B
build/block-library/blocks/columns/style-rtl.css 406 B
build/block-library/blocks/columns/style.css 406 B
build/block-library/blocks/comment-author-avatar/editor-rtl.css 125 B
build/block-library/blocks/comment-author-avatar/editor.css 125 B
build/block-library/blocks/comment-content/style-rtl.css 92 B
build/block-library/blocks/comment-content/style.css 92 B
build/block-library/blocks/comment-template/style-rtl.css 127 B
build/block-library/blocks/comment-template/style.css 127 B
build/block-library/blocks/comments-pagination-numbers/editor-rtl.css 123 B
build/block-library/blocks/comments-pagination-numbers/editor.css 121 B
build/block-library/blocks/comments-pagination/editor-rtl.css 222 B
build/block-library/blocks/comments-pagination/editor.css 209 B
build/block-library/blocks/comments-pagination/style-rtl.css 235 B
build/block-library/blocks/comments-pagination/style.css 231 B
build/block-library/blocks/comments-title/editor-rtl.css 75 B
build/block-library/blocks/comments-title/editor.css 75 B
build/block-library/blocks/comments/editor-rtl.css 95 B
build/block-library/blocks/comments/editor.css 95 B
build/block-library/blocks/cover/editor-rtl.css 546 B
build/block-library/blocks/cover/editor.css 547 B
build/block-library/blocks/cover/style-rtl.css 1.53 kB
build/block-library/blocks/cover/style.css 1.53 kB
build/block-library/blocks/embed/editor-rtl.css 293 B
build/block-library/blocks/embed/editor.css 293 B
build/block-library/blocks/embed/style-rtl.css 417 B
build/block-library/blocks/embed/style.css 417 B
build/block-library/blocks/embed/theme-rtl.css 124 B
build/block-library/blocks/embed/theme.css 124 B
build/block-library/blocks/file/editor-rtl.css 300 B
build/block-library/blocks/file/editor.css 300 B
build/block-library/blocks/file/style-rtl.css 255 B
build/block-library/blocks/file/style.css 255 B
build/block-library/blocks/file/view.min.js 353 B
build/block-library/blocks/freeform/editor-rtl.css 2.44 kB
build/block-library/blocks/freeform/editor.css 2.44 kB
build/block-library/blocks/gallery/editor-rtl.css 961 B
build/block-library/blocks/gallery/editor.css 964 B
build/block-library/blocks/gallery/style-rtl.css 1.51 kB
build/block-library/blocks/gallery/style.css 1.51 kB
build/block-library/blocks/gallery/theme-rtl.css 122 B
build/block-library/blocks/gallery/theme.css 122 B
build/block-library/blocks/group/editor-rtl.css 333 B
build/block-library/blocks/group/editor.css 333 B
build/block-library/blocks/group/style-rtl.css 57 B
build/block-library/blocks/group/style.css 57 B
build/block-library/blocks/group/theme-rtl.css 78 B
build/block-library/blocks/group/theme.css 78 B
build/block-library/blocks/heading/style-rtl.css 76 B
build/block-library/blocks/heading/style.css 76 B
build/block-library/blocks/html/editor-rtl.css 332 B
build/block-library/blocks/html/editor.css 333 B
build/block-library/blocks/image/editor-rtl.css 731 B
build/block-library/blocks/image/editor.css 730 B
build/block-library/blocks/image/style-rtl.css 529 B
build/block-library/blocks/image/style.css 535 B
build/block-library/blocks/image/theme-rtl.css 124 B
build/block-library/blocks/image/theme.css 124 B
build/block-library/blocks/latest-comments/style-rtl.css 284 B
build/block-library/blocks/latest-comments/style.css 284 B
build/block-library/blocks/latest-posts/editor-rtl.css 199 B
build/block-library/blocks/latest-posts/editor.css 198 B
build/block-library/blocks/latest-posts/style-rtl.css 463 B
build/block-library/blocks/latest-posts/style.css 462 B
build/block-library/blocks/list/style-rtl.css 88 B
build/block-library/blocks/list/style.css 88 B
build/block-library/blocks/media-text/editor-rtl.css 266 B
build/block-library/blocks/media-text/editor.css 263 B
build/block-library/blocks/media-text/style-rtl.css 493 B
build/block-library/blocks/media-text/style.css 490 B
build/block-library/blocks/more/editor-rtl.css 431 B
build/block-library/blocks/more/editor.css 431 B
build/block-library/blocks/navigation-link/editor-rtl.css 708 B
build/block-library/blocks/navigation-link/editor.css 706 B
build/block-library/blocks/navigation-link/style-rtl.css 115 B
build/block-library/blocks/navigation-link/style.css 115 B
build/block-library/blocks/navigation-submenu/editor-rtl.css 299 B
build/block-library/blocks/navigation-submenu/editor.css 299 B
build/block-library/blocks/navigation-submenu/view.min.js 375 B
build/block-library/blocks/navigation/editor-rtl.css 2.03 kB
build/block-library/blocks/navigation/editor.css 2.04 kB
build/block-library/blocks/navigation/style-rtl.css 1.95 kB
build/block-library/blocks/navigation/style.css 1.94 kB
build/block-library/blocks/navigation/view-modal.min.js 2.78 kB
build/block-library/blocks/navigation/view.min.js 395 B
build/block-library/blocks/nextpage/editor-rtl.css 395 B
build/block-library/blocks/nextpage/editor.css 395 B
build/block-library/blocks/page-list/editor-rtl.css 363 B
build/block-library/blocks/page-list/editor.css 363 B
build/block-library/blocks/page-list/style-rtl.css 175 B
build/block-library/blocks/page-list/style.css 175 B
build/block-library/blocks/paragraph/editor-rtl.css 157 B
build/block-library/blocks/paragraph/editor.css 157 B
build/block-library/blocks/paragraph/style-rtl.css 260 B
build/block-library/blocks/paragraph/style.css 260 B
build/block-library/blocks/post-author/style-rtl.css 175 B
build/block-library/blocks/post-author/style.css 176 B
build/block-library/blocks/post-comments-form/editor-rtl.css 69 B
build/block-library/blocks/post-comments-form/editor.css 69 B
build/block-library/blocks/post-comments-form/style-rtl.css 495 B
build/block-library/blocks/post-comments-form/style.css 495 B
build/block-library/blocks/post-comments/editor-rtl.css 77 B
build/block-library/blocks/post-comments/editor.css 77 B
build/block-library/blocks/post-comments/style-rtl.css 628 B
build/block-library/blocks/post-comments/style.css 628 B
build/block-library/blocks/post-excerpt/editor-rtl.css 73 B
build/block-library/blocks/post-excerpt/editor.css 73 B
build/block-library/blocks/post-excerpt/style-rtl.css 69 B
build/block-library/blocks/post-excerpt/style.css 69 B
build/block-library/blocks/post-featured-image/editor-rtl.css 721 B
build/block-library/blocks/post-featured-image/editor.css 721 B
build/block-library/blocks/post-featured-image/style-rtl.css 153 B
build/block-library/blocks/post-featured-image/style.css 153 B
build/block-library/blocks/post-template/editor-rtl.css 99 B
build/block-library/blocks/post-template/editor.css 98 B
build/block-library/blocks/post-template/style-rtl.css 323 B
build/block-library/blocks/post-template/style.css 323 B
build/block-library/blocks/post-terms/style-rtl.css 73 B
build/block-library/blocks/post-terms/style.css 73 B
build/block-library/blocks/post-title/style-rtl.css 80 B
build/block-library/blocks/post-title/style.css 80 B
build/block-library/blocks/preformatted/style-rtl.css 103 B
build/block-library/blocks/preformatted/style.css 103 B
build/block-library/blocks/pullquote/editor-rtl.css 198 B
build/block-library/blocks/pullquote/editor.css 198 B
build/block-library/blocks/pullquote/style-rtl.css 370 B
build/block-library/blocks/pullquote/style.css 370 B
build/block-library/blocks/pullquote/theme-rtl.css 167 B
build/block-library/blocks/pullquote/theme.css 167 B
build/block-library/blocks/query-pagination-numbers/editor-rtl.css 122 B
build/block-library/blocks/query-pagination-numbers/editor.css 121 B
build/block-library/blocks/query-pagination/editor-rtl.css 221 B
build/block-library/blocks/query-pagination/editor.css 211 B
build/block-library/blocks/query-pagination/style-rtl.css 234 B
build/block-library/blocks/query-pagination/style.css 231 B
build/block-library/blocks/query/editor-rtl.css 369 B
build/block-library/blocks/query/editor.css 369 B
build/block-library/blocks/quote/style-rtl.css 213 B
build/block-library/blocks/quote/style.css 213 B
build/block-library/blocks/quote/theme-rtl.css 223 B
build/block-library/blocks/quote/theme.css 226 B
build/block-library/blocks/read-more/style-rtl.css 132 B
build/block-library/blocks/read-more/style.css 132 B
build/block-library/blocks/rss/editor-rtl.css 202 B
build/block-library/blocks/rss/editor.css 204 B
build/block-library/blocks/rss/style-rtl.css 289 B
build/block-library/blocks/rss/style.css 288 B
build/block-library/blocks/search/editor-rtl.css 165 B
build/block-library/blocks/search/editor.css 165 B
build/block-library/blocks/search/style-rtl.css 397 B
build/block-library/blocks/search/style.css 398 B
build/block-library/blocks/search/theme-rtl.css 64 B
build/block-library/blocks/search/theme.css 64 B
build/block-library/blocks/separator/editor-rtl.css 146 B
build/block-library/blocks/separator/editor.css 146 B
build/block-library/blocks/separator/style-rtl.css 233 B
build/block-library/blocks/separator/style.css 233 B
build/block-library/blocks/separator/theme-rtl.css 194 B
build/block-library/blocks/separator/theme.css 194 B
build/block-library/blocks/shortcode/editor-rtl.css 474 B
build/block-library/blocks/shortcode/editor.css 474 B
build/block-library/blocks/site-logo/editor-rtl.css 759 B
build/block-library/blocks/site-logo/editor.css 759 B
build/block-library/blocks/site-logo/style-rtl.css 181 B
build/block-library/blocks/site-logo/style.css 181 B
build/block-library/blocks/site-tagline/editor-rtl.css 86 B
build/block-library/blocks/site-tagline/editor.css 86 B
build/block-library/blocks/site-title/editor-rtl.css 84 B
build/block-library/blocks/site-title/editor.css 84 B
build/block-library/blocks/social-link/editor-rtl.css 177 B
build/block-library/blocks/social-link/editor.css 177 B
build/block-library/blocks/social-links/editor-rtl.css 674 B
build/block-library/blocks/social-links/editor.css 673 B
build/block-library/blocks/social-links/style-rtl.css 1.37 kB
build/block-library/blocks/social-links/style.css 1.36 kB
build/block-library/blocks/spacer/editor-rtl.css 332 B
build/block-library/blocks/spacer/editor.css 332 B
build/block-library/blocks/spacer/style-rtl.css 48 B
build/block-library/blocks/spacer/style.css 48 B
build/block-library/blocks/table/editor-rtl.css 504 B
build/block-library/blocks/table/editor.css 504 B
build/block-library/blocks/table/style-rtl.css 625 B
build/block-library/blocks/table/style.css 625 B
build/block-library/blocks/table/theme-rtl.css 188 B
build/block-library/blocks/table/theme.css 188 B
build/block-library/blocks/tag-cloud/style-rtl.css 226 B
build/block-library/blocks/tag-cloud/style.css 227 B
build/block-library/blocks/template-part/editor-rtl.css 149 B
build/block-library/blocks/template-part/editor.css 149 B
build/block-library/blocks/template-part/theme-rtl.css 101 B
build/block-library/blocks/template-part/theme.css 101 B
build/block-library/blocks/text-columns/editor-rtl.css 95 B
build/block-library/blocks/text-columns/editor.css 95 B
build/block-library/blocks/text-columns/style-rtl.css 166 B
build/block-library/blocks/text-columns/style.css 166 B
build/block-library/blocks/verse/style-rtl.css 87 B
build/block-library/blocks/verse/style.css 87 B
build/block-library/blocks/video/editor-rtl.css 571 B
build/block-library/blocks/video/editor.css 572 B
build/block-library/blocks/video/style-rtl.css 173 B
build/block-library/blocks/video/style.css 173 B
build/block-library/blocks/video/theme-rtl.css 124 B
build/block-library/blocks/video/theme.css 124 B
build/block-library/common-rtl.css 993 B
build/block-library/common.css 990 B
build/block-library/editor-rtl.css 10.3 kB
build/block-library/editor.css 10.3 kB
build/block-library/index.min.js 180 kB
build/block-library/reset-rtl.css 478 B
build/block-library/reset.css 478 B
build/block-library/style-rtl.css 11.5 kB
build/block-library/style.css 11.6 kB
build/block-library/theme-rtl.css 689 B
build/block-library/theme.css 694 B
build/block-serialization-default-parser/index.min.js 1.12 kB
build/block-serialization-spec-parser/index.min.js 2.83 kB
build/blocks/index.min.js 47 kB
build/components/style-rtl.css 15 kB
build/components/style.css 15.1 kB
build/compose/index.min.js 11.7 kB
build/core-data/index.min.js 14.6 kB
build/customize-widgets/index.min.js 11.2 kB
build/customize-widgets/style-rtl.css 1.39 kB
build/customize-widgets/style.css 1.39 kB
build/data-controls/index.min.js 663 B
build/data/index.min.js 7.98 kB
build/date/index.min.js 32 kB
build/deprecated/index.min.js 518 B
build/dom-ready/index.min.js 336 B
build/dom/index.min.js 4.69 kB
build/edit-navigation/index.min.js 16 kB
build/edit-navigation/style-rtl.css 4.05 kB
build/edit-navigation/style.css 4.06 kB
build/edit-post/classic-rtl.css 546 B
build/edit-post/classic.css 547 B
build/edit-post/index.min.js 30.4 kB
build/edit-post/style-rtl.css 7.08 kB
build/edit-post/style.css 7.09 kB
build/edit-site/index.min.js 47.8 kB
build/edit-site/style-rtl.css 7.97 kB
build/edit-site/style.css 7.95 kB
build/edit-widgets/index.min.js 16.4 kB
build/edit-widgets/style-rtl.css 4.41 kB
build/edit-widgets/style.css 4.41 kB
build/editor/index.min.js 38.5 kB
build/editor/style-rtl.css 3.71 kB
build/editor/style.css 3.71 kB
build/element/index.min.js 4.3 kB
build/escape-html/index.min.js 548 B
build/format-library/index.min.js 6.62 kB
build/format-library/style-rtl.css 571 B
build/format-library/style.css 571 B
build/hooks/index.min.js 1.66 kB
build/html-entities/index.min.js 454 B
build/i18n/index.min.js 3.79 kB
build/is-shallow-equal/index.min.js 535 B
build/keyboard-shortcuts/index.min.js 1.83 kB
build/keycodes/index.min.js 1.41 kB
build/list-reusable-blocks/index.min.js 1.75 kB
build/list-reusable-blocks/style-rtl.css 838 B
build/list-reusable-blocks/style.css 838 B
build/media-utils/index.min.js 2.9 kB
build/notices/index.min.js 957 B
build/nux/index.min.js 2.1 kB
build/nux/style-rtl.css 751 B
build/nux/style.css 749 B
build/plugins/index.min.js 1.98 kB
build/preferences-persistence/index.min.js 2.23 kB
build/preferences/index.min.js 1.32 kB
build/primitives/index.min.js 949 B
build/priority-queue/index.min.js 628 B
build/react-i18n/index.min.js 704 B
build/react-refresh-entry/index.min.js 8.44 kB
build/react-refresh-runtime/index.min.js 7.31 kB
build/redux-routine/index.min.js 2.69 kB
build/reusable-blocks/index.min.js 2.24 kB
build/reusable-blocks/style-rtl.css 256 B
build/reusable-blocks/style.css 256 B
build/rich-text/index.min.js 11.2 kB
build/server-side-render/index.min.js 1.61 kB
build/shortcode/index.min.js 1.52 kB
build/token-list/index.min.js 668 B
build/url/index.min.js 1.99 kB
build/vendors/react-dom.min.js 38.5 kB
build/vendors/react.min.js 4.34 kB
build/viewport/index.min.js 1.08 kB
build/warning/index.min.js 280 B
build/widgets/index.min.js 7.21 kB
build/widgets/style-rtl.css 1.16 kB
build/widgets/style.css 1.16 kB
build/wordcount/index.min.js 1.07 kB

compressed-size-action

@youknowriad
Copy link
Contributor Author

Great work here @stokesman I think it's probably ready to merge right?

@stokesman
Copy link
Contributor

Actually it turns out the e2e test failure here is legit. ColorPicker (like RangeControl before #40568) depends on InputControl not updating from props while focused. I'm sure it can be solved and I'd be glad to take that on. I don't think I can make time for it this week though.

@stokesman
Copy link
Contributor

I've discovered another thing that this PR was breaking but wasn't being caught by current tests. BoxControl isn't calling onChange when blurred after clearing the input:

regressed-blur-commit-empty-box-control.mp4

I've got some PRs to update unit tests that will ensure this would be caught. I also have a branch that fixes the issue and obviates the changes introduced from #40568. I think I'll go ahead and revert those here and get the new branch merged into this one.

youknowriad and others added 6 commits May 29, 2022 13:25
* Update `RangeControl` to play nice with revised `InputControl`

* Update `UnitControl` to play nice with revised `InputControl`

* Restore controlled mode to `RangeControl`

* Add missing ;

* Add comment after deleting `onChange`

* Update test of `RangeControl` to also test controlled mode

* Remove separate onChange call from reset handling in `RangeControl`

* Refine RESET logic of `InputControl` reducer

* Simplify refined RESET logic of `InputControl` reducer

* Restore initial position of `RangeControl` when without value

* Differentiate state sync effect hooks by event existence

* Add and use type `SecondaryReducer`

* Cleanup legacy `event.perist()`

* Simplify update from props in reducer

Co-authored-by: Lena Morita <lena@jaguchi.com>

* Ensure event is cleared after drag actions

* Avoid declaration of potential unused variable

* Add more reset unit tests for `RangeControl`

* Run `RangeControl` unit test in both controlled/uncontrolled modes

* Make “keep invaid values” test async

* Prevent interference of value entry in number input

* Remove unused `floatClamp` function

* Fix reset to `initialPosition`

* Fix a couple tests for controlled `RangeControl`

* Fix `RangeControl` reset

* Ensure `InputControl`’s state syncing works after focus changes

* Comment

* Ignore NaN values in `useUnimpededRangedNumberEntry`

* Refine use of event existence in state syncing effect hooks

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>
Co-authored-by: Lena Morita <lena@jaguchi.com>
Copy link
Contributor

@stokesman stokesman left a comment

Choose a reason for hiding this comment

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

I wanted to provide some explanation of the latest commits and provide a diff for extended unit testing.

A rather large diff with the unit test changes from #41421 and ##41422 and #40568 I used to test this
diff --git a/packages/components/src/box-control/test/index.js b/packages/components/src/box-control/test/index.js
index 4515c4885c..c98dd3b48d 100644
--- a/packages/components/src/box-control/test/index.js
+++ b/packages/components/src/box-control/test/index.js
@@ -1,19 +1,29 @@
 /**
  * External dependencies
  */
-import { render, fireEvent, screen } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 
 /**
  * WordPress dependencies
  */
 import { useState } from '@wordpress/element';
-import { ENTER } from '@wordpress/keycodes';
 
 /**
  * Internal dependencies
  */
 import BoxControl from '../';
 
+const setupUser = () =>
+	userEvent.setup( {
+		advanceTimers: jest.advanceTimersByTime,
+	} );
+
+const getInput = () =>
+	screen.getByLabelText( 'Box Control', { selector: 'input' } );
+const getSelect = () => screen.getByLabelText( 'Select unit' );
+const getReset = () => screen.getByText( /Reset/ );
+
 describe( 'BoxControl', () => {
 	describe( 'Basic rendering', () => {
 		it( 'should render', () => {
@@ -23,42 +33,41 @@ describe( 'BoxControl', () => {
 			expect( input ).toBeTruthy();
 		} );
 
-		it( 'should update values when interacting with input', () => {
-			const { container } = render( <BoxControl /> );
-			const input = container.querySelector( 'input' );
-			const unitSelect = container.querySelector( 'select' );
+		it( 'should update values when interacting with input', async () => {
+			const user = setupUser();
+			render( <BoxControl /> );
+			const input = getInput();
+			const select = getSelect();
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100%' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100%' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( '%' );
+			expect( input ).toHaveValue( '100' );
+			expect( select ).toHaveValue( '%' );
 		} );
 	} );
 
 	describe( 'Reset', () => {
-		it( 'should reset values when clicking Reset', () => {
-			const { container, getByText } = render( <BoxControl /> );
-			const input = container.querySelector( 'input' );
-			const unitSelect = container.querySelector( 'select' );
-			const reset = getByText( /Reset/ );
+		it( 'should reset values when clicking Reset', async () => {
+			const user = setupUser();
+			render( <BoxControl /> );
+			const input = getInput();
+			const select = getSelect();
+			const reset = getReset();
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100px' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100px' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '100' );
+			expect( select ).toHaveValue( 'px' );
 
-			reset.focus();
-			fireEvent.click( reset );
+			await user.click( reset );
 
-			expect( input.value ).toBe( '' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '' );
+			expect( select ).toHaveValue( 'px' );
 		} );
 
-		it( 'should reset values when clicking Reset, if controlled', () => {
+		it( 'should reset values when clicking Reset, if controlled', async () => {
 			const Example = () => {
 				const [ state, setState ] = useState();
 
@@ -69,26 +78,25 @@ describe( 'BoxControl', () => {
 					/>
 				);
 			};
-			const { container, getByText } = render( <Example /> );
-			const input = container.querySelector( 'input' );
-			const unitSelect = container.querySelector( 'select' );
-			const reset = getByText( /Reset/ );
+			const user = setupUser();
+			render( <Example /> );
+			const input = getInput();
+			const select = getSelect();
+			const reset = getReset();
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100px' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100px' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '100' );
+			expect( select ).toHaveValue( 'px' );
 
-			reset.focus();
-			fireEvent.click( reset );
+			await user.click( reset );
 
-			expect( input.value ).toBe( '' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '' );
+			expect( select ).toHaveValue( 'px' );
 		} );
 
-		it( 'should reset values when clicking Reset, if controlled <-> uncontrolled state changes', () => {
+		it( 'should reset values when clicking Reset, if controlled <-> uncontrolled state changes', async () => {
 			const Example = () => {
 				const [ state, setState ] = useState();
 
@@ -106,70 +114,78 @@ describe( 'BoxControl', () => {
 					/>
 				);
 			};
-			const { container, getByText } = render( <Example /> );
-			const input = container.querySelector( 'input' );
-			const unitSelect = container.querySelector( 'select' );
-			const reset = getByText( /Reset/ );
+			const user = setupUser();
+			render( <Example /> );
+			const input = getInput();
+			const select = getSelect();
+			const reset = getReset();
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100px' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100px' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '100' );
+			expect( select ).toHaveValue( 'px' );
 
-			reset.focus();
-			fireEvent.click( reset );
+			await user.click( reset );
 
-			expect( input.value ).toBe( '' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '' );
+			expect( select ).toHaveValue( 'px' );
 		} );
 
-		it( 'should persist cleared value when focus changes', () => {
-			render( <BoxControl /> );
+		it( 'should persist cleared value when focus changes', async () => {
+			const user = setupUser();
+			const spyChange = jest.fn();
+			render( <BoxControl onChange={ ( v ) => spyChange( v ) } /> );
 			const input = screen.getByLabelText( 'Box Control', {
 				selector: 'input',
 			} );
 			const unitSelect = screen.getByLabelText( 'Select unit' );
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100%' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100%' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( '%' );
+			expect( input ).toHaveValue( '100' );
+			expect( unitSelect ).toHaveValue( '%' );
 
-			fireEvent.change( input, { target: { value: '' } } );
-			fireEvent.blur( input );
+			await user.clear( input );
+			expect( input ).toHaveValue( '' );
+			// Clicking document.body to trigger a blur event on the input.
+			await user.click( document.body );
 
-			expect( input.value ).toBe( '' );
+			expect( input ).toHaveValue( '' );
+			expect( spyChange ).toHaveBeenLastCalledWith( {
+				top: undefined,
+				right: undefined,
+				bottom: undefined,
+				left: undefined,
+			} );
 		} );
 	} );
 
 	describe( 'Unlinked Sides', () => {
-		it( 'should update a single side value when unlinked', () => {
+		it( 'should update a single side value when unlinked', async () => {
 			let state = {};
 			const setState = ( newState ) => ( state = newState );
 
-			const { container, getByLabelText } = render(
+			const { getAllByLabelText, getByLabelText } = render(
 				<BoxControl
 					values={ state }
 					onChange={ ( next ) => setState( next ) }
 				/>
 			);
-
+			const user = setupUser();
 			const unlink = getByLabelText( /Unlink Sides/ );
-			fireEvent.click( unlink );
 
-			const input = container.querySelector( 'input' );
-			const unitSelect = container.querySelector( 'select' );
+			await user.click( unlink );
+
+			const input = getByLabelText( /Top/ );
+			const select = getAllByLabelText( /Select unit/ )[ 0 ];
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100px' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100px' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '100' );
+			expect( select ).toHaveValue( 'px' );
 
 			expect( state ).toEqual( {
 				top: '100px',
@@ -179,30 +195,30 @@ describe( 'BoxControl', () => {
 			} );
 		} );
 
-		it( 'should update a whole axis when value is changed when unlinked', () => {
+		it( 'should update a whole axis when value is changed when unlinked', async () => {
 			let state = {};
 			const setState = ( newState ) => ( state = newState );
 
-			const { container, getByLabelText } = render(
+			const { getAllByLabelText, getByLabelText } = render(
 				<BoxControl
 					values={ state }
 					onChange={ ( next ) => setState( next ) }
 					splitOnAxis={ true }
 				/>
 			);
-
+			const user = setupUser();
 			const unlink = getByLabelText( /Unlink Sides/ );
-			fireEvent.click( unlink );
 
-			const input = container.querySelector( 'input' );
-			const unitSelect = container.querySelector( 'select' );
+			await user.click( unlink );
+
+			const input = getByLabelText( /Vertical/ );
+			const select = getAllByLabelText( /Select unit/ )[ 0 ];
 
-			input.focus();
-			fireEvent.change( input, { target: { value: '100px' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '100px' );
+			await user.keyboard( '{Enter}' );
 
-			expect( input.value ).toBe( '100' );
-			expect( unitSelect.value ).toBe( 'px' );
+			expect( input ).toHaveValue( '100' );
+			expect( select ).toHaveValue( 'px' );
 
 			expect( state ).toEqual( {
 				top: '100px',
@@ -214,36 +230,34 @@ describe( 'BoxControl', () => {
 	} );
 
 	describe( 'Unit selections', () => {
-		it( 'should update unlinked controls unit selection based on all input control', () => {
+		it( 'should update unlinked controls unit selection based on all input control', async () => {
 			// Render control.
 			render( <BoxControl /> );
+			const user = setupUser();
 
 			// Make unit selection on all input control.
-			const allUnitSelect = screen.getByLabelText( 'Select unit' );
-			allUnitSelect.focus();
-			fireEvent.change( allUnitSelect, { target: { value: 'em' } } );
+			const allUnitSelect = getSelect();
+			await user.selectOptions( allUnitSelect, [ 'em' ] );
 
 			// Unlink the controls.
-			const unlink = screen.getByLabelText( /Unlink Sides/ );
-			fireEvent.click( unlink );
+			await user.click( screen.getByLabelText( /Unlink Sides/ ) );
 
 			// Confirm that each individual control has the selected unit
 			const unlinkedSelects = screen.getAllByDisplayValue( 'em' );
 			expect( unlinkedSelects.length ).toEqual( 4 );
 		} );
 
-		it( 'should use individual side attribute unit when available', () => {
+		it( 'should use individual side attribute unit when available', async () => {
 			// Render control.
 			const { rerender } = render( <BoxControl /> );
+			const user = setupUser();
 
 			// Make unit selection on all input control.
-			const allUnitSelect = screen.getByLabelText( 'Select unit' );
-			allUnitSelect.focus();
-			fireEvent.change( allUnitSelect, { target: { value: 'vw' } } );
+			const allUnitSelect = getSelect();
+			await user.selectOptions( allUnitSelect, [ 'vw' ] );
 
 			// Unlink the controls.
-			const unlink = screen.getByLabelText( /Unlink Sides/ );
-			fireEvent.click( unlink );
+			await user.click( screen.getByLabelText( /Unlink Sides/ ) );
 
 			// Confirm that each individual control has the selected unit
 			const unlinkedSelects = screen.getAllByDisplayValue( 'vw' );
@@ -261,18 +275,15 @@ describe( 'BoxControl', () => {
 	} );
 
 	describe( 'onChange updates', () => {
-		it( 'should call onChange when values contain more than just CSS units', () => {
+		it( 'should call onChange when values contain more than just CSS units', async () => {
 			const setState = jest.fn();
 
 			render( <BoxControl onChange={ setState } /> );
+			const user = setupUser();
+			const input = getInput();
 
-			const input = screen.getByLabelText( 'Box Control', {
-				selector: 'input',
-			} );
-
-			input.focus();
-			fireEvent.change( input, { target: { value: '7.5rem' } } );
-			fireEvent.keyDown( input, { keyCode: ENTER } );
+			await user.type( input, '7.5rem' );
+			await user.keyboard( '{Enter}' );
 
 			expect( setState ).toHaveBeenCalledWith( {
 				top: '7.5rem',
@@ -282,14 +293,14 @@ describe( 'BoxControl', () => {
 			} );
 		} );
 
-		it( 'should not pass invalid CSS unit only values to onChange', () => {
+		it( 'should not pass invalid CSS unit only values to onChange', async () => {
 			const setState = jest.fn();
 
 			render( <BoxControl onChange={ setState } /> );
+			const user = setupUser();
 
-			const allUnitSelect = screen.getByLabelText( 'Select unit' );
-			allUnitSelect.focus();
-			fireEvent.change( allUnitSelect, { target: { value: 'rem' } } );
+			const allUnitSelect = getSelect();
+			await user.selectOptions( allUnitSelect, 'rem' );
 
 			expect( setState ).toHaveBeenCalledWith( {
 				top: undefined,
diff --git a/packages/components/src/input-control/test/index.js b/packages/components/src/input-control/test/index.js
index c7d18217eb..fe3668db2b 100644
--- a/packages/components/src/input-control/test/index.js
+++ b/packages/components/src/input-control/test/index.js
@@ -1,13 +1,19 @@
 /**
  * External dependencies
  */
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 
 /**
  * Internal dependencies
  */
 import BaseInputControl from '../';
 
+const setupUser = () =>
+	userEvent.setup( {
+		advanceTimers: jest.advanceTimersByTime,
+	} );
+
 const getInput = () => screen.getByTestId( 'input' );
 
 describe( 'InputControl', () => {
@@ -42,48 +48,52 @@ describe( 'InputControl', () => {
 	} );
 
 	describe( 'Ensurance of focus for number inputs', () => {
-		it( 'should focus its input on mousedown events', () => {
+		it( 'should focus its input on mousedown events', async () => {
+			const user = setupUser();
 			const spy = jest.fn();
 			render( <InputControl type="number" onFocus={ spy } /> );
+			const target = getInput();
 
-			const input = getInput();
-			fireEvent.mouseDown( input );
+			// Hovers the input and presses (without releasing) primary button.
+			await user.pointer( [
+				{ target },
+				{ keys: '[MouseLeft]', target },
+			] );
 
 			expect( spy ).toHaveBeenCalledTimes( 1 );
 		} );
 	} );
 
 	describe( 'Value', () => {
-		it( 'should update value onChange', () => {
+		it( 'should update value onChange', async () => {
+			const user = setupUser();
 			const spy = jest.fn();
 			render( <InputControl value="Hello" onChange={ spy } /> );
-
 			const input = getInput();
-			input.focus();
-			fireEvent.change( input, { target: { value: 'There' } } );
 
-			expect( input.value ).toBe( 'There' );
-			expect( spy ).toHaveBeenCalledTimes( 1 );
+			await user.type( input, ' there' );
+
+			expect( input ).toHaveValue( 'Hello there' );
+			expect( spy ).toHaveBeenCalledTimes( 6 );
 		} );
 
-		it( 'should work as a controlled component', () => {
+		it( 'should work as a controlled component', async () => {
+			const user = setupUser();
 			const spy = jest.fn();
 			const { rerender } = render(
 				<InputControl value="one" onChange={ spy } />
 			);
-
 			const input = getInput();
 
-			input.focus();
-			fireEvent.change( input, { target: { value: 'two' } } );
+			await user.type( input, '2' );
 
 			// Ensuring <InputControl /> is controlled.
-			fireEvent.blur( input );
+			await user.click( document.body );
 
-			// Updating the value.
+			// Updating the value via props.
 			rerender( <InputControl value="three" onChange={ spy } /> );
 
-			expect( input.value ).toBe( 'three' );
+			expect( input ).toHaveValue( 'three' );
 
 			/*
 			 * onChange called only once. onChange is not called when a
@@ -98,7 +108,6 @@ describe( 'InputControl', () => {
 			const { rerender } = render(
 				<InputControl value="Original" onChange={ spy } />
 			);
-
 			const input = getInput();
 
 			// Assuming <InputControl /> is controlled (not focused)
@@ -106,12 +115,12 @@ describe( 'InputControl', () => {
 			// Updating the value.
 			rerender( <InputControl value="New" onChange={ spy } /> );
 
-			expect( input.value ).toBe( 'New' );
+			expect( input ).toHaveValue( 'New' );
 
 			// Change it back to the original value.
 			rerender( <InputControl value="Original" onChange={ spy } /> );
 
-			expect( input.value ).toBe( 'Original' );
+			expect( input ).toHaveValue( 'Original' );
 			expect( spy ).toHaveBeenCalledTimes( 0 );
 		} );
 	} );
diff --git a/packages/components/src/range-control/test/index.js b/packages/components/src/range-control/test/index.js
index c60f12b3b7..f27759012a 100644
--- a/packages/components/src/range-control/test/index.js
+++ b/packages/components/src/range-control/test/index.js
@@ -3,6 +3,11 @@
  */
 import { fireEvent, render } from '@testing-library/react';
 
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
 /**
  * Internal dependencies
  */
@@ -15,14 +20,26 @@ const getNumberInput = ( container ) =>
 const getResetButton = ( container ) =>
 	container.querySelector( '.components-range-control__reset' );
 
-describe( 'RangeControl', () => {
+function ControlledRangeControl( props ) {
+	const [ value, setValue ] = useState( props.value );
+	const onChange = ( v ) => {
+		setValue( v );
+		props.onChange?.( v );
+	};
+	return <RangeControl { ...props } onChange={ onChange } value={ value } />;
+}
+
+describe.each( [
+	[ 'uncontrolled', RangeControl ],
+	[ 'controlled', ControlledRangeControl ],
+] )( 'RangeControl %s', ( ...modeAndComponent ) => {
+	const [ mode, Component ] = modeAndComponent;
+
 	describe( '#render()', () => {
 		it( 'should trigger change callback with numeric value', () => {
 			const onChange = jest.fn();
 
-			const { container } = render(
-				<RangeControl onChange={ onChange } />
-			);
+			const { container } = render( <Component onChange={ onChange } /> );
 
 			const rangeInput = getRangeInput( container );
 			const numberInput = getNumberInput( container );
@@ -39,10 +56,7 @@ describe( 'RangeControl', () => {
 
 		it( 'should render with icons', () => {
 			const { container } = render(
-				<RangeControl
-					beforeIcon="format-image"
-					afterIcon="format-video"
-				/>
+				<Component beforeIcon="format-image" afterIcon="format-video" />
 			);
 
 			const beforeIcon = container.querySelector(
@@ -59,7 +73,7 @@ describe( 'RangeControl', () => {
 
 	describe( 'validation', () => {
 		it( 'should not apply if new value is lower than minimum', () => {
-			const { container } = render( <RangeControl min={ 11 } /> );
+			const { container } = render( <Component min={ 11 } /> );
 
 			const rangeInput = getRangeInput( container );
 			const numberInput = getNumberInput( container );
@@ -71,7 +85,7 @@ describe( 'RangeControl', () => {
 		} );
 
 		it( 'should not apply if new value is greater than maximum', () => {
-			const { container } = render( <RangeControl max={ 20 } /> );
+			const { container } = render( <Component max={ 20 } /> );
 
 			const rangeInput = getRangeInput( container );
 			const numberInput = getNumberInput( container );
@@ -85,7 +99,7 @@ describe( 'RangeControl', () => {
 		it( 'should not call onChange if new value is invalid', () => {
 			const onChange = jest.fn();
 			const { container } = render(
-				<RangeControl onChange={ onChange } min={ 10 } max={ 20 } />
+				<Component onChange={ onChange } min={ 10 } max={ 20 } />
 			);
 
 			const numberInput = getNumberInput( container );
@@ -99,7 +113,7 @@ describe( 'RangeControl', () => {
 		it( 'should keep invalid values in number input until loss of focus', () => {
 			const onChange = jest.fn();
 			const { container } = render(
-				<RangeControl onChange={ onChange } min={ -1 } max={ 1 } />
+				<Component onChange={ onChange } min={ -1 } max={ 1 } />
 			);
 
 			const rangeInput = getRangeInput( container );
@@ -118,7 +132,7 @@ describe( 'RangeControl', () => {
 
 		it( 'should validate when provided a max or min of zero', () => {
 			const { container } = render(
-				<RangeControl min={ -100 } max={ 0 } />
+				<Component min={ -100 } max={ 0 } />
 			);
 
 			const rangeInput = getRangeInput( container );
@@ -133,7 +147,7 @@ describe( 'RangeControl', () => {
 
 		it( 'should validate when min and max are negative', () => {
 			const { container } = render(
-				<RangeControl min={ -100 } max={ -50 } />
+				<Component min={ -100 } max={ -50 } />
 			);
 
 			const rangeInput = getRangeInput( container );
@@ -154,11 +168,7 @@ describe( 'RangeControl', () => {
 		it( 'should take into account the step starting from min', () => {
 			const onChange = jest.fn();
 			const { container } = render(
-				<RangeControl
-					onChange={ onChange }
-					min={ 0.1 }
-					step={ 0.125 }
-				/>
+				<Component onChange={ onChange } min={ 0.1 } step={ 0.125 } />
 			);
 
 			const rangeInput = getRangeInput( container );
@@ -179,9 +189,7 @@ describe( 'RangeControl', () => {
 
 	describe( 'initialPosition / value', () => {
 		it( 'should render initial rendered value of 50% of min/max, if no initialPosition or value is defined', () => {
-			const { container } = render(
-				<RangeControl min={ 0 } max={ 10 } />
-			);
+			const { container } = render( <Component min={ 0 } max={ 10 } /> );
 
 			const rangeInput = getRangeInput( container );
 
@@ -190,7 +198,7 @@ describe( 'RangeControl', () => {
 
 		it( 'should render initialPosition if no value is provided', () => {
 			const { container } = render(
-				<RangeControl initialPosition={ 50 } />
+				<Component initialPosition={ 50 } />
 			);
 
 			const rangeInput = getRangeInput( container );
@@ -200,7 +208,7 @@ describe( 'RangeControl', () => {
 
 		it( 'should render value instead of initialPosition is provided', () => {
 			const { container } = render(
-				<RangeControl initialPosition={ 50 } value={ 10 } />
+				<Component initialPosition={ 50 } value={ 10 } />
 			);
 
 			const rangeInput = getRangeInput( container );
@@ -211,7 +219,7 @@ describe( 'RangeControl', () => {
 
 	describe( 'input field', () => {
 		it( 'should render an input field by default', () => {
-			const { container } = render( <RangeControl /> );
+			const { container } = render( <Component /> );
 
 			const numberInput = getNumberInput( container );
 
@@ -220,7 +228,7 @@ describe( 'RangeControl', () => {
 
 		it( 'should not render an input field, if disabled', () => {
 			const { container } = render(
-				<RangeControl withInputField={ false } />
+				<Component withInputField={ false } />
 			);
 
 			const numberInput = getNumberInput( container );
@@ -229,7 +237,7 @@ describe( 'RangeControl', () => {
 		} );
 
 		it( 'should render a zero value into input range and field', () => {
-			const { container } = render( <RangeControl value={ 0 } /> );
+			const { container } = render( <Component value={ 0 } /> );
 
 			const rangeInput = getRangeInput( container );
 			const numberInput = getNumberInput( container );
@@ -239,7 +247,7 @@ describe( 'RangeControl', () => {
 		} );
 
 		it( 'should update both field and range on change', () => {
-			const { container } = render( <RangeControl /> );
+			const { container } = render( <Component /> );
 
 			const rangeInput = getRangeInput( container );
 			const numberInput = getNumberInput( container );
@@ -258,7 +266,7 @@ describe( 'RangeControl', () => {
 		} );
 
 		it( 'should reset input values if next value is removed', () => {
-			const { container } = render( <RangeControl /> );
+			const { container } = render( <Component /> );
 
 			const rangeInput = getRangeInput( container );
 			const numberInput = getNumberInput( container );
@@ -274,14 +282,31 @@ describe( 'RangeControl', () => {
 	} );
 
 	describe( 'reset', () => {
-		it( 'should reset to a custom fallback value, defined by a parent component', () => {
+		it.concurrent.each( [
+			[
+				'initialPosition if it is defined',
+				{ initialPosition: 21 },
+				[ '21', undefined ],
+			],
+			[
+				'resetFallbackValue if it is defined',
+				{ resetFallbackValue: 34 },
+				[ '34', 34 ],
+			],
+			[
+				'resetFallbackValue if both it and initialPosition are defined',
+				{ initialPosition: 21, resetFallbackValue: 34 },
+				[ '34', 34 ],
+			],
+		] )( 'should reset to %s', ( ...all ) => {
+			const [ , propsForReset, [ expectedValue, expectedChange ] ] = all;
 			const spy = jest.fn();
 			const { container } = render(
-				<RangeControl
-					initialPosition={ 10 }
+				<Component
 					allowReset={ true }
 					onChange={ spy }
-					resetFallbackValue={ 33 }
+					{ ...propsForReset }
+					value={ mode === 'controlled' ? 89 : undefined }
 				/>
 			);
 
@@ -291,19 +316,20 @@ describe( 'RangeControl', () => {
 
 			fireEvent.click( resetButton );
 
-			expect( rangeInput.value ).toBe( '33' );
-			expect( numberInput.value ).toBe( '33' );
-			expect( spy ).toHaveBeenCalledWith( 33 );
+			expect( rangeInput.value ).toBe( expectedValue );
+			expect( numberInput.value ).toBe( expectedValue );
+			expect( spy ).toHaveBeenCalledWith( expectedChange );
 		} );
 
 		it( 'should reset to a 50% of min/max value, of no initialPosition or value is defined', () => {
 			const { container } = render(
-				<RangeControl
+				<Component
 					initialPosition={ undefined }
 					min={ 0 }
 					max={ 100 }
 					allowReset={ true }
 					resetFallbackValue={ undefined }
+					value={ mode === 'controlled' ? 89 : undefined }
 				/>
 			);
 

8952d45 Introduces a useDraft hook only used by InputControl that re-implements its current behavior of keeping the entered value in favor of an update from props. The crucial difference is that it will do so only for a single render and so does not block undo/redo. This allows components that depend on the behavior to continue doing so with no changes.

32eb218 This fixes the issue I highlighted in #40518 (comment). The issue stems from the how the component is supposed to behave when isPressEnterToChange set to true. That is, its value is updated with every change but onChange is not called until the input is blurred (or Enter is pressed). Because the value has already updated the hook isn't triggered and onChange is not called. Triggering the effect when isDirty changes fixes it.

The diff I provided can be used while checking out this commit to see the unit tests that will fail.

bb6b932 This came about because there was a unit test for BoxControl failing:

  ● BoxControl › Reset › should persist cleared value when focus changes

    expect(received).toBe(expected) // Object.is equality

    Expected: ""
    Received: "100"

I didn't actually reproduce it testing manually (only tried in Firefox) but I had my suspicions about this from all the previous work and found this change to fix it.

The remaining two commits are quite self-explanatory.

Comment on lines 227 to 232
reset(
initialState.value,
currentState.current._event as SyntheticEvent
);
dispatch( {
type: actions.RESET,
payload: { value: initialState.value },
} );
Copy link
Contributor

Choose a reason for hiding this comment

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

Two reasons for switching to dispatch:

  • reset is typed to require passing an event for the second argument and I didn't want to change the typing because in most cases it would be important to pass the event.
  • The exhaustive-deps lint rule knows dispatch is stable and won't complain when it's not in the dependencies.

My remaining peeve with this is I think it'd be better to not reuse RESET here and maybe reintroduce UPDATE. It's only a theoretical concern only and for now a non-issue. The concern would be that someday a component wants to specialize RESET in a way that should not happen every time the value prop has changed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the explanation of dispatch over reset, it makes sense. I also agree regarding RESET vs UPDATE and that it is a potential future concern but that can be addressed down the line and separately to this PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

This sounds like a good point, if we were to keep the internal reducer!

At the same time, after this fix is merged I'd like to explore ways to simplify InputControl (and consequently its derived components), including the removal of the internal reducer (see this comment for more details)

Copy link
Contributor

@aaronrobertshaw aaronrobertshaw left a comment

Choose a reason for hiding this comment

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

Great work @stokesman pushing this forward!

Special thanks for the explanatory comments they are fantastic and make a big difference when it comes to reviewing. 🙇

The changes look good and test well for me.

✅ Could still replicate the original issue on trunk
✅ Applying this PR does fix the problem
✅ Tested InputControl, NumberControl, RangeControl, UnitControl, and BoxControl via the editor and storybook and didn't encounter issues
RangeControl issue with resetting to the initialPosition remains fixed
✅ The ColorPicker hex input field works
BoxControl now calls onChange when blurred after clearing the input
✅ No typing errors
✅ Unit tests are passing for all the control components noted above
✅ e2e failure appears unrelated to me

@ciampo ciampo changed the title Fix undo when changing padding values InputControl: Fix undo when changing padding values May 30, 2022
Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

Just echoing @aaronrobertshaw:

  • code changes LGTM (and the detailed explanations definitely helped!)
  • had a look at InputControl and related in Storybook, everything seems to work as expected
  • played around in the editor — the original bug is fixed, and I couldn't spot any regressions
  • unit tests and e2e all pass (I re-ran the failing e2e tests and they passed)

🚀

@stokesman stokesman merged commit d5cc0dc into trunk May 30, 2022
@stokesman stokesman deleted the fix/input-field-reset-behavior branch May 30, 2022 17:00
@stokesman
Copy link
Contributor

Thank you Aaron and Marco for the testing and reviews and Riad for the tidy initial work!

@github-actions github-actions bot added this to the Gutenberg 13.4 milestone May 30, 2022
youknowriad added a commit that referenced this pull request Jun 3, 2022
* Fix undo when changing padding values

* Add changelog entry

* Have `InputControl` favor entered value for a render cycle

* Run propagation effect when `isDirty` changes

* Use event existence to eliminate extraneous `onChange` calls

* Keep change handler in a ref to pass `react-hooks/exhaustive-deps`

* Cleanup legacy `event.persist` calls

Co-authored-by: Mitchell Austin <mr.fye@oneandthesame.net>
@aaronrobertshaw
Copy link
Contributor

It appears I missed an edge case during my testing of this PR.

When a BoxControl, BorderControl or BorderRadiusControl (and maybe other compound components) are in their expanded view and reset via the panel's menu, the input fields do not clear. A git bisect pointed to this PR.

An issue has been created for this (#42455) but thought I'd note it here as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] UI Components Impacts or related to the UI component system [Type] Bug An existing feature does not function as intended
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants