Commit 84e0c39072a for woocommerce

commit 84e0c39072a30e2bd4a759f85c5f9db0ef28c3cb
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Sep 2 17:13:41 2026 +0300

    Fix Form setValue dropping a write with a zero-segment name (#68224)

    * Fix Form setValue dropping a write with a zero-segment name

    [Context]

    setValue() hands setValues() only the entry a write touched, deriving
    that key from lodash toPath( name ) and the value from the object
    setWith() returned.

    [Problem]

    The two disagree for a name that yields no path segments. toPath()
    returns [] for '', '[', null and [], so segments[0] is undefined, while
    setWith() still writes each of those names as a literal key. The patch
    became { undefined: undefined }: the caller's value was dropped and a
    junk 'undefined' key was merged into the form state and reported to
    onChange and onChanges.

    Trunk had neither effect, since it handed setValues() the whole values
    object rather than an entry derived from a separate path resolution.

    The README already promises that paths "resolve the way lodash set
    resolves them", and this was the one case where they did not.

    [Solution]

    Fall back to the literal String( name ) when toPath() yields no
    segments, which is the key setWith() writes for exactly these names.
    That restores the previous behavior for all four of them and leaves
    every other name resolving as before.

    The name undefined was never affected: reading newValues[ undefined ]
    coerces to the same 'undefined' key setWith() wrote, so it already
    round-tripped correctly. It is covered so the case stays pinned.

    Refs #37168
    Refs #37169

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Add changelog entry for the Form setValue zero-segment name fix

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix(components): Pass the normalized path to setWith in Form setValue

    setValue derived the write's key from the normalized path segments but
    still handed lodash the original name. For an empty-array name, toPath()
    yields no segments, so the key fell back to '' while setWith() treated
    the empty array as a no-op. The result was an '' entry with an undefined
    value being reported as a change.

    Hand setWith() the same segments the key read uses, so the write and the
    reported entry can no longer diverge. Every other input produces the same
    result as before, since the segments already match what lodash derives
    from the name.

    Refs #68224

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/packages/js/components/changelog/fix-form-setvalue-zero-segment-name b/packages/js/components/changelog/fix-form-setvalue-zero-segment-name
new file mode 100644
index 00000000000..1404cddbc8b
--- /dev/null
+++ b/packages/js/components/changelog/fix-form-setvalue-zero-segment-name
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Form setValue dropping a write whose name yields no path segments, such as an empty string.
diff --git a/packages/js/components/src/form/form.tsx b/packages/js/components/src/form/form.tsx
index 777d614e355..7103cd4f8e0 100644
--- a/packages/js/components/src/form/form.tsx
+++ b/packages/js/components/src/form/form.tsx
@@ -183,13 +183,18 @@ function FormComponent< Values extends Record< string, any > = any >(
 		( name: keyof Values, value: any ) => {
 			// lodash writes an existing literal key such as 'a.b' in place rather
 			// than as a path, so only split a name the form does not already hold.
-			const segments = Object.prototype.hasOwnProperty.call(
+			const path = Object.prototype.hasOwnProperty.call(
 				pendingValuesRef.current,
 				name
 			)
 				? [ String( name ) ]
 				: _toPath( name );

+			// toPath() yields no segments for a name such as '' or null. Write
+			// those under the literal key instead, and hand setWith() the same
+			// segments so the entry read below is the one the write landed on.
+			const segments = path.length ? path : [ String( name ) ];
+
 			// lodash drops a write whose path steps through one of these keys.
 			// Drop it here too: otherwise the entry picked below reads an
 			// inherited value and setValues adds it to the form as an own key.
@@ -203,7 +208,7 @@ function FormComponent< Values extends Record< string, any > = any >(

 			const newValues = _setWith(
 				{ ...pendingValuesRef.current },
-				name,
+				segments,
 				value,
 				_clone
 			);
diff --git a/packages/js/components/src/form/test/state-updates.tsx b/packages/js/components/src/form/test/state-updates.tsx
index c9a6c0cf8fc..3bb01eb12ce 100644
--- a/packages/js/components/src/form/test/state-updates.tsx
+++ b/packages/js/components/src/form/test/state-updates.tsx
@@ -605,4 +605,44 @@ describe( 'Form state updates', () => {
 		expect( onChange ).not.toHaveBeenCalled();
 		expect( onChanges.mock.calls ).toEqual( [ [ [], initialValues, true ] ] );
 	} );
+
+	// lodash's toPath() yields no segments for these names. setWith() writes
+	// each one as a literal key, except an empty array, which it drops.
+	it.each( [ '', '[', undefined, null, [] ] )(
+		'writes the zero-segment name %p under the key it lands on',
+		( name ) => {
+			const initialValues: Record< string, unknown > = { other: 2 };
+			const { onChange, onChanges } = renderForm(
+				initialValues,
+				( { setValue } ) => (
+					<button
+						onClick={ () =>
+							setValue(
+								name as unknown as string,
+								'Updated'
+							)
+						}
+					>
+						Write zero-segment name
+					</button>
+				)
+			);
+
+			userEvent.click(
+				screen.getByRole( 'button', {
+					name: 'Write zero-segment name',
+				} )
+			);
+
+			const key = String( name );
+			const nextValues = { ...initialValues, [ key ]: 'Updated' };
+			expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+			expect( onChange.mock.calls ).toEqual( [
+				[ { name: key, value: 'Updated' }, nextValues, true ],
+			] );
+			expect( onChanges.mock.calls ).toEqual( [
+				[ [ { name: key, value: 'Updated' } ], nextValues, true ],
+			] );
+		}
+	);
 } );