Repository navigation
ValidatedToggleControl: Fix validation timing with callback ref #75095
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /** | ||
| * External dependencies | ||
| */ | ||
| import { render } from '@testing-library/react'; | ||
|
|
||
| /** | ||
| * WordPress dependencies | ||
| */ | ||
| import { useRef, useLayoutEffect } from '@wordpress/element'; | ||
|
|
||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import { ValidatedToggleControl } from '../'; | ||
|
|
||
| describe( 'ValidatedToggleControl', () => { | ||
| it( 'should validate correctly when checkValidity is called before useEffect runs', () => { | ||
| // `useLayoutEffect` runs AFTER render but BEFORE `useEffect`. | ||
| // This simulates code that runs before useEffect, such as validation | ||
| // triggered immediately when a form card expands. | ||
| let validityResult: boolean | null = null; | ||
| function TestComponent() { | ||
| const ref = useRef< HTMLInputElement >( null ); | ||
| useLayoutEffect( () => { | ||
| // This runs BEFORE useEffect would run | ||
| if ( ref.current ) { | ||
| validityResult = ref.current.checkValidity(); | ||
| } | ||
| }, [] ); | ||
| return ( | ||
| <ValidatedToggleControl | ||
| ref={ ref } | ||
| label="Required toggle" | ||
| required | ||
| checked={ false } | ||
| onChange={ () => {} } | ||
| /> | ||
| ); | ||
| } | ||
| render( <TestComponent /> ); | ||
| // `required` is set synchronously, so unchecked box fails validation. | ||
| expect( validityResult ).toBe( false ); | ||
| } ); | ||
| } ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| /** | ||
| * WordPress dependencies | ||
| */ | ||
| import { forwardRef, useRef, useEffect } from '@wordpress/element'; | ||
| import { forwardRef, useRef, useCallback } from '@wordpress/element'; | ||
| import { useMergeRefs } from '@wordpress/compose'; | ||
|
|
||
| /** | ||
|
|
@@ -22,16 +22,22 @@ const UnforwardedValidatedToggleControl = ( | |
| }: React.ComponentProps< typeof ToggleControl > & ValidatedControlProps, | ||
| forwardedRef: React.ForwardedRef< HTMLInputElement > | ||
| ) => { | ||
| const validityTargetRef = useRef< HTMLInputElement >( null ); | ||
| const mergedRefs = useMergeRefs( [ forwardedRef, validityTargetRef ] ); | ||
|
|
||
| const validityTargetRef = useRef< HTMLInputElement | null >( null ); | ||
| // TODO: Upstream limitation - The `required` attribute is not passed down to the input, | ||
| // so we need to set it manually. | ||
| useEffect( () => { | ||
| if ( validityTargetRef.current ) { | ||
| validityTargetRef.current.required = required ?? false; | ||
| } | ||
| }, [ required ] ); | ||
| // so we need to set it manually. Using a callback ref ensures `required` is set | ||
| // synchronously when the element mounts, before any validation effects run. | ||
| const setRequiredRef = useCallback( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to be a
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you don't want the ref callback to be called on every re-render, we should use
Ref: https://react.dev/reference/react-dom/components/common#ref-callback-parameters |
||
| ( element: HTMLInputElement | null ) => { | ||
| if ( ! element ) { | ||
| return; | ||
| } | ||
| validityTargetRef.current = element; | ||
| element.required = required ?? false; | ||
| }, | ||
| [ required ] | ||
| ); | ||
|
|
||
| const mergedRefs = useMergeRefs( [ forwardedRef, setRequiredRef ] ); | ||
|
|
||
| return ( | ||
| <ControlWithError | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the reason for this change? And similarly in the
setRequiredRefargument typings below, would it ever actually be passednull, or could it beHTMLInputElement?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to have a reference of the node for
getValidityTargetbelow and since we use a callback ref, we need to makevalidityTargetRefmutable (by adding the| null). Without it the ref is readonly.It can be passed null, yes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Noting that the next sentence of that quoted documentation is "This behavior will be removed in a future version", but tracks with how it behaves today 👍 But we also have a guard with early return that would that
nullassignment. Not a blocker in any case.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Considering our current rate of React upgrades, the "future version", might be in far future 😅