Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Internal

- Expose `useDrag` from `@use-gesture/react` package via private API's ([#66735](https://github.com/WordPress/gutenberg/pull/66735)).
- ValidatedToggleControl: Fix validation timing with callback ref. ([#75095](https://github.com/WordPress/gutenberg/pull/75095)).

## 32.1.0 (2026-01-29)

Expand Down
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';

/**
Expand All @@ -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 );

Copy link
Copy Markdown
Member

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 setRequiredRef argument typings below, would it ever actually be passed null, or could it be HTMLInputElement ?

@ntsekouras ntsekouras Feb 2, 2026 •

Copy link
Copy Markdown
Contributor Author

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?

We need to have a reference of the node for getValidityTarget below and since we use a callback ref, we need to make validityTargetRef mutable (by adding the | null). Without it the ref is readonly.

It can be passed null, yes.

If a function is not returned by the ref callback, React will call the callback again with null as the argument when the ref gets detached.

Copy link
Copy Markdown
Member

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 null assignment. Not a blocker in any case.

Copy link
Copy Markdown
Member

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 😅

// 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this need to be a useCallback, or could it just be a plain function?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this need to be a useCallback, or could it just be a plain function?

If you don't want the ref callback to be called on every re-render, we should use useCallback.

Unless you pass the same function reference for the ref callback on every render, the callback will get temporarily cleanup and re-create during every re-render of the component.

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
Expand Down
Loading