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 ],
+ ] );
+ }
+ );
} );