From 9ed5c89ae00d75121e3d1432d7b17377bf7a1cc3 Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Fri, 30 Jan 2026 16:57:53 +0200 Subject: [PATCH 1/3] ValidatedToggleControl: Fix validation timing with callback ref --- .../components/toggle-control.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/components/src/validated-form-controls/components/toggle-control.tsx b/packages/components/src/validated-form-controls/components/toggle-control.tsx index 332b64e1e3214b..f9c1cf5db9ae6f 100644 --- a/packages/components/src/validated-form-controls/components/toggle-control.tsx +++ b/packages/components/src/validated-form-controls/components/toggle-control.tsx @@ -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,21 @@ 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( + ( element: HTMLInputElement | null ) => { + validityTargetRef.current = element; + if ( element ) { + element.required = required ?? false; + } + }, + [ required ] + ); + + const mergedRefs = useMergeRefs( [ forwardedRef, setRequiredRef ] ); return ( Date: Fri, 30 Jan 2026 17:15:27 +0200 Subject: [PATCH 2/3] changelog entry --- packages/components/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index bf563f7a64b1b0..3304aefa4fe06c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -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) From 83ab062a65aa97b9be252dcff8551a5d5060e5b7 Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Mon, 2 Feb 2026 11:57:15 +0200 Subject: [PATCH 3/3] add test --- .../components/test/index.tsx | 44 +++++++++++++++++++ .../components/toggle-control.tsx | 7 +-- 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/validated-form-controls/components/test/index.tsx diff --git a/packages/components/src/validated-form-controls/components/test/index.tsx b/packages/components/src/validated-form-controls/components/test/index.tsx new file mode 100644 index 00000000000000..a55defd24522b5 --- /dev/null +++ b/packages/components/src/validated-form-controls/components/test/index.tsx @@ -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 ( + {} } + /> + ); + } + render( ); + // `required` is set synchronously, so unchecked box fails validation. + expect( validityResult ).toBe( false ); + } ); +} ); diff --git a/packages/components/src/validated-form-controls/components/toggle-control.tsx b/packages/components/src/validated-form-controls/components/toggle-control.tsx index f9c1cf5db9ae6f..965e30b06c63bc 100644 --- a/packages/components/src/validated-form-controls/components/toggle-control.tsx +++ b/packages/components/src/validated-form-controls/components/toggle-control.tsx @@ -28,10 +28,11 @@ const UnforwardedValidatedToggleControl = ( // synchronously when the element mounts, before any validation effects run. const setRequiredRef = useCallback( ( element: HTMLInputElement | null ) => { - validityTargetRef.current = element; - if ( element ) { - element.required = required ?? false; + if ( ! element ) { + return; } + validityTargetRef.current = element; + element.required = required ?? false; }, [ required ] );