Commit a77782f57e1 for woocommerce

commit a77782f57e17362485c340143c35f52eefe74085
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Sep 11 23:49:34 2026 +0300

    Fix Form setValue writing names that are not paths to the wrong field (#68571)

    * fix(components): resolve Form setValue names the way lodash set() does

    In 11.1.0, Form's setValue() handed the name to lodash setWith(),
    which treats a name as one literal key unless it is a dotted or
    balanced-bracket path, or the object already holds it. The
    unreleased rewrite in #68050 and #68224 splits every name the form
    does not hold with toPath() instead. Names that are not real paths
    now land somewhere else: setValue( 'a[', v ) overwrites an existing
    'a', names such as 'items[0', '[x' and 1.5 are split, and an empty
    array writes under ''.

    Copy lodash's isKey() check and resolve the name against the pending
    values before splitting it, so every name lands where 11.1.0 put it.
    An empty path is a no-op, as in lodash, and the prototype-pollution
    guard from #68050 still applies to the resolved segments. Nothing
    that shipped changes; the README's path rule now covers these cases.

    Refs WOOAIRR-215

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

    * fix(components): treat every symbol name as a key, as lodash does

    The isKey() copy added in the previous commit left out lodash's
    isSymbol() term. A bare symbol still returned early on the typeof
    check, but a boxed one, Object( Symbol( 'x' ) ), is typeof 'object',
    so it fell through to PLAIN_KEY.test(), which stringifies its
    argument and throws "Cannot convert a Symbol value to a string". In
    11.1.0 the same call stored the value under the symbol.

    Add the isSymbol() term so the copy matches lodash's isKey() and the
    name is wrapped rather than split. Cover it with a test, along with
    the '[.[' name beside the empty-array path: toPath() yields no
    segments for it, so lodash writes nothing, while trunk writes it as
    a literal key.

    Refs WOOAIRR-215

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

    * docs(components): note where the isKey() copy differs from lodash

    The comment says the check is copied from lodash's isKey(), but the
    last term is not identical: lodash ends with `value in Object( object )`
    and this ends with `name in object`. The wrapper only guards a nullish
    object, which the typed parameter and the single caller rule out, so
    the behavior matches. Say so, rather than leaving the next person to
    diff the two and find an unexplained difference.

    Refs WOOAIRR-215

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

    ---------

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

diff --git a/packages/js/components/changelog/fix-form-setvalue-lodash-key-resolution b/packages/js/components/changelog/fix-form-setvalue-lodash-key-resolution
new file mode 100644
index 00000000000..500e594b1f8
--- /dev/null
+++ b/packages/js/components/changelog/fix-form-setvalue-lodash-key-resolution
@@ -0,0 +1,3 @@
+Significance: patch
+Type: fix
+Comment: Follow-up to #68050 and #68224, both unreleased. Form setValue again resolves a name as one literal key or a path the way lodash set() does, which is what the last release did, so no shipped behavior changes.
diff --git a/packages/js/components/src/form/README.md b/packages/js/components/src/form/README.md
index de5804d882e..f8058390ff3 100644
--- a/packages/js/components/src/form/README.md
+++ b/packages/js/components/src/form/README.md
@@ -79,8 +79,8 @@ To see the properties available within `useFormContext()`, check out the [`FormC
 `onChange` and `onChanges` report only the entries a call wrote. The complete next form state is always the second argument, so a consumer that just needs the new values can read that and ignore the reported entries entirely. A consumer that needs to react to fields it did not write should read `values` rather than wait for a notification about them.

 -   `setValue( name, value )` reports exactly one entry: the top-level key it wrote. For a flat name that is the name you passed. Sibling fields are never reported, even when the form holds other values.
--   A nested write, in dot or bracket notation, is reported under its top-level key with the updated subtree. With `dimensions: { width: 1, height: 2 }` in the form, `setValue( 'dimensions.width', 5 )` reports `{ name: 'dimensions', value: { width: 5, height: 2 } }`. Paths resolve the way lodash `set` resolves them, so a form that holds a literal key such as `'a.b'` has that key written and reported as is.
--   `setValue( name, value )` is a no-op when the path steps through `__proto__`, `constructor` or `prototype`. lodash refuses to write those keys, so the state does not change and nothing is reported. A literal key that merely contains one of them, such as `'a.constructor'`, is written normally, since lodash writes an existing literal key in place rather than as a path.
+-   A nested write, in dot or bracket notation, is reported under its top-level key with the updated subtree. With `dimensions: { width: 1, height: 2 }` in the form, `setValue( 'dimensions.width', 5 )` reports `{ name: 'dimensions', value: { width: 5, height: 2 } }`. Paths resolve the way lodash `set` resolves them, so a form that holds a literal key such as `'a.b'` has that key written and reported as is, and a name that is not a dotted or balanced-bracket path, such as `'a['`, is written as one literal key.
+-   `setValue( name, value )` is a no-op when the path steps through `__proto__`, `constructor` or `prototype`, or is an empty array. lodash refuses to write those keys and writes nothing for an empty path, so the state does not change and nothing is reported. A literal key that merely contains one of them, such as `'a.constructor'`, is written normally, since lodash writes an existing literal key in place rather than as a path.
 -   `setValues( patch )` shallow-merges `patch` into the form state and reports the patch's own keys in JavaScript key order. A key `patch` inherits from its prototype is neither merged nor reported.
 -   `resetForm()` replaces the state silently and reports nothing.
 -   Every call is a distinct logical change. Repeated writes to one field, and writes that set a field to the value it already holds, are each reported in call order. Values are never compared for equality and changes are never deduplicated.
diff --git a/packages/js/components/src/form/form.tsx b/packages/js/components/src/form/form.tsx
index 7103cd4f8e0..f8e4f0c23fb 100644
--- a/packages/js/components/src/form/form.tsx
+++ b/packages/js/components/src/form/form.tsx
@@ -18,6 +18,7 @@ import _get from 'lodash/get';
 import _clone from 'lodash/clone';
 import _toPath from 'lodash/toPath';
 import _isEqual from 'lodash/isEqual';
+import _isSymbol from 'lodash/isSymbol';
 import _omit from 'lodash/omit';

 /**
@@ -45,6 +46,36 @@ function isChangeEvent< T >(
 // Path segments lodash refuses to write through.
 const UNWRITABLE_KEYS = [ '__proto__', 'constructor', 'prototype' ];

+// Copied from lodash's private isKey(), the check setWith() uses to decide
+// whether a name is one literal key or a path to split. A name is a key when
+// it is not a dotted or balanced-bracket path, or when the object holds it.
+// The one deviation is the last term, which drops lodash's Object() wrapper
+// around the object, since the only caller always passes a plain object.
+const PLAIN_KEY = /^\w*$/;
+const DEEP_PATH = /\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/;
+
+function isKey( name: unknown, object: object ): boolean {
+	if ( Array.isArray( name ) ) {
+		return false;
+	}
+	const type = typeof name;
+	if (
+		type === 'number' ||
+		type === 'symbol' ||
+		type === 'boolean' ||
+		name === null ||
+		name === undefined ||
+		_isSymbol( name )
+	) {
+		return true;
+	}
+	return (
+		PLAIN_KEY.test( name as string ) ||
+		! DEEP_PATH.test( name as string ) ||
+		( name as PropertyKey ) in object
+	);
+}
+
 /**
  * A form component to handle form state and provide input helper props.
  */
@@ -181,24 +212,20 @@ function FormComponent< Values extends Record< string, any > = any >(
 	const setValue = useCallback(
 		// eslint-disable-next-line @typescript-eslint/no-explicit-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 path = Object.prototype.hasOwnProperty.call(
-				pendingValuesRef.current,
-				name
-			)
-				? [ String( name ) ]
+			const newValues = { ...pendingValuesRef.current };
+			// Resolve the name the way setWith() does. Wrapping a literal key in
+			// an array keeps toPath() from splitting it, and setWith() gets the
+			// same segments so the entry read below is the one the write landed on.
+			const segments = isKey( name, newValues )
+				? _toPath( [ 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.
+			// lodash writes nothing for an empty path and drops a write whose
+			// path steps through one of these keys. Drop both here too, so
+			// setValues never adds a key the write did not make, such as an
+			// inherited value.
 			if (
+				! segments.length ||
 				segments.some( ( segment ) =>
 					UNWRITABLE_KEYS.includes( segment )
 				)
@@ -206,12 +233,7 @@ function FormComponent< Values extends Record< string, any > = any >(
 				return;
 			}

-			const newValues = _setWith(
-				{ ...pendingValuesRef.current },
-				segments,
-				value,
-				_clone
-			);
+			_setWith( newValues, segments, value, _clone );
 			// Hand setValues only the entry this write touched so it reports one
 			// change: a literal key is its own only segment, and a path reports
 			// under its top-level key.
diff --git a/packages/js/components/src/form/test/state-updates.tsx b/packages/js/components/src/form/test/state-updates.tsx
index 3bb01eb12ce..3ee069b464c 100644
--- a/packages/js/components/src/form/test/state-updates.tsx
+++ b/packages/js/components/src/form/test/state-updates.tsx
@@ -606,10 +606,22 @@ describe( 'Form state updates', () => {
 		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',
+	// setWith() writes each of these names as one literal key, since none is a
+	// dotted or balanced-bracket path. toPath() alone would split some of them
+	// ('a[' becomes 'a') or yield no segments at all ('', '[', null).
+	it.each( [
+		'',
+		'[',
+		undefined,
+		null,
+		'a[',
+		'a]',
+		'a[b',
+		'[x',
+		'items[0',
+		1.5,
+	] )(
+		'writes the name %p under the literal key lodash set() uses',
 		( name ) => {
 			const initialValues: Record< string, unknown > = { other: 2 };
 			const { onChange, onChanges } = renderForm(
@@ -617,21 +629,16 @@ describe( 'Form state updates', () => {
 				( { setValue } ) => (
 					<button
 						onClick={ () =>
-							setValue(
-								name as unknown as string,
-								'Updated'
-							)
+							setValue( name as unknown as string, 'Updated' )
 						}
 					>
-						Write zero-segment name
+						Write literal name
 					</button>
 				)
 			);

 			userEvent.click(
-				screen.getByRole( 'button', {
-					name: 'Write zero-segment name',
-				} )
+				screen.getByRole( 'button', { name: 'Write literal name' } )
 			);

 			const key = String( name );
@@ -645,4 +652,136 @@ describe( 'Form state updates', () => {
 			] );
 		}
 	);
+
+	it( 'leaves a held field alone when a name with an unbalanced bracket starts with it', () => {
+		const { onChange, onChanges } = renderForm(
+			{ a: 5, other: 2 },
+			( { setValue } ) => (
+				<button onClick={ () => setValue( 'a[', 'Updated' ) }>
+					Write unbalanced name
+				</button>
+			)
+		);
+
+		userEvent.click(
+			screen.getByRole( 'button', { name: 'Write unbalanced name' } )
+		);
+
+		const nextValues = { a: 5, other: 2, 'a[': 'Updated' };
+		expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+		expect( onChange.mock.calls ).toEqual( [
+			[ { name: 'a[', value: 'Updated' }, nextValues, true ],
+		] );
+		expect( onChanges.mock.calls ).toEqual( [
+			[ [ { name: 'a[', value: 'Updated' } ], nextValues, true ],
+		] );
+	} );
+
+	it( 'writes an array name as a path even when the form holds its joined key', () => {
+		const { onChange, onChanges } = renderForm(
+			{ 'a,b': 1, other: 2 } as Record< string, unknown >,
+			( { setValue } ) => (
+				<button
+					onClick={ () =>
+						setValue( [ 'a', 'b' ] as unknown as string, 'Updated' )
+					}
+				>
+					Write array name
+				</button>
+			)
+		);
+
+		userEvent.click(
+			screen.getByRole( 'button', { name: 'Write array name' } )
+		);
+
+		const nextValues = { 'a,b': 1, other: 2, a: { b: 'Updated' } };
+		expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+		expect( onChange.mock.calls ).toEqual( [
+			[ { name: 'a', value: { b: 'Updated' } }, nextValues, true ],
+		] );
+		expect( onChanges.mock.calls ).toEqual( [
+			[ [ { name: 'a', value: { b: 'Updated' } } ], nextValues, true ],
+		] );
+	} );
+
+	it( 'drops a write to an empty-array path, as lodash set() does', () => {
+		const initialValues: Record< string, unknown > = { other: 2 };
+		const { validate, onChange, onChanges } = renderForm(
+			initialValues,
+			( { setValue } ) => (
+				<button
+					onClick={ () =>
+						setValue( [] as unknown as string, 'Updated' )
+					}
+				>
+					Write empty path
+				</button>
+			)
+		);
+
+		userEvent.click(
+			screen.getByRole( 'button', { name: 'Write empty path' } )
+		);
+
+		expect( renderedValues() ).toBe( JSON.stringify( initialValues ) );
+		expect( validate ).not.toHaveBeenCalled();
+		expect( onChange ).not.toHaveBeenCalled();
+		expect( onChanges ).not.toHaveBeenCalled();
+	} );
+
+	it( 'drops a write to a bracketed name with no segments, as lodash set() does', () => {
+		const initialValues: Record< string, unknown > = { other: 2 };
+		const { validate, onChange, onChanges } = renderForm(
+			initialValues,
+			( { setValue } ) => (
+				<button onClick={ () => setValue( '[.[', 'Updated' ) }>
+					Write bracketed name
+				</button>
+			)
+		);
+
+		userEvent.click(
+			screen.getByRole( 'button', { name: 'Write bracketed name' } )
+		);
+
+		expect( renderedValues() ).toBe( JSON.stringify( initialValues ) );
+		expect( validate ).not.toHaveBeenCalled();
+		expect( onChange ).not.toHaveBeenCalled();
+		expect( onChanges ).not.toHaveBeenCalled();
+	} );
+
+	// lodash treats every symbol as a key, boxed ones included, so the name never
+	// reaches the string checks, which would throw on it.
+	it( 'writes a boxed symbol name as a key, as lodash set() does', () => {
+		const name = Object( Symbol( 'boxed' ) );
+		const initialValues: Record< string, unknown > = { other: 2 };
+		let latest: Record< string, unknown > = {};
+		const { onChange, onChanges } = renderForm(
+			initialValues,
+			( { setValue, values } ) => {
+				latest = values;
+				return (
+					<button
+						onClick={ () =>
+							setValue( name as unknown as string, 'Updated' )
+						}
+					>
+						Write boxed symbol
+					</button>
+				);
+			}
+		);
+
+		userEvent.click(
+			screen.getByRole( 'button', { name: 'Write boxed symbol' } )
+		);
+
+		// The rendered output goes through JSON.stringify(), which drops symbol keys.
+		expect( Object.getOwnPropertySymbols( latest ) ).toHaveLength( 1 );
+		expect( latest[ name as unknown as string ] ).toBe( 'Updated' );
+		// setValues() reports through Object.keys(), so a symbol write names nothing.
+		expect( onChange ).not.toHaveBeenCalled();
+		expect( onChanges ).toHaveBeenCalledWith( [], latest, true );
+	} );
 } );